diff --git a/.gitignore b/.gitignore
index a879176..ae6fa6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@ __pycache__/
*.indigoPlugin.zip
.coverage
docs/branding/.build/
+bridge-node/node_modules/
+bridge-node/dist/
+bridge-node/.test-build/
diff --git a/bridge-node/package-lock.json b/bridge-node/package-lock.json
new file mode 100644
index 0000000..ebdbbde
--- /dev/null
+++ b/bridge-node/package-lock.json
@@ -0,0 +1,200 @@
+{
+ "name": "indigo-matter-bridge",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "indigo-matter-bridge",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@matter/main": "0.17.8",
+ "@matter/nodejs": "0.17.8",
+ "ws": "8.21.2"
+ },
+ "bin": {
+ "indigo-matter-bridge": "dist/main.js"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.1",
+ "@types/ws": "^8.18.1",
+ "typescript": "^5.9.3"
+ },
+ "engines": {
+ "node": ">=22.13.0"
+ }
+ },
+ "node_modules/@matter/general": {
+ "version": "0.17.8",
+ "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.17.8.tgz",
+ "integrity": "sha512-gSjnMG2fXtXoAqvUWe8HXdJLl1QzR1I6YWVflWWomEghNymUdz/y2aY46R0e87x8fFkuDRFchVX8t1C00eG8bQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@noble/curves": "^2.2.0"
+ }
+ },
+ "node_modules/@matter/main": {
+ "version": "0.17.8",
+ "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.17.8.tgz",
+ "integrity": "sha512-+KHxt0OU1GnrhfFsfFOWRfXHtukgBt4g8fWZ7hPO4NwFfFsZgd8zyHqioUgRbru/hIRqZjqljZGnWkJlkwfxyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.8",
+ "@matter/model": "0.17.8",
+ "@matter/node": "0.17.8",
+ "@matter/protocol": "0.17.8",
+ "@matter/types": "0.17.8"
+ },
+ "optionalDependencies": {
+ "@matter/nodejs": "0.17.8"
+ }
+ },
+ "node_modules/@matter/model": {
+ "version": "0.17.8",
+ "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.17.8.tgz",
+ "integrity": "sha512-vAOv/YdFuexjOD3cHibvFU8uMtAxgbC8GJg8395OXJy4PRcLW7hV1TPtA5+X14I+yxCtZyqE3fylV2y1t1AVkw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.8"
+ }
+ },
+ "node_modules/@matter/node": {
+ "version": "0.17.8",
+ "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.17.8.tgz",
+ "integrity": "sha512-EexLTjwgtEwT4j3AtNs32o/DzvMuS/tkm6XVUPOZjAQijl4qKUjNYcy3UxSzLQQkxD0MpwIbTzd5bH4gD7SIjA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.8",
+ "@matter/model": "0.17.8",
+ "@matter/protocol": "0.17.8",
+ "@matter/types": "0.17.8"
+ }
+ },
+ "node_modules/@matter/nodejs": {
+ "version": "0.17.8",
+ "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.17.8.tgz",
+ "integrity": "sha512-UytC/qovZpltsc2o5wsNu65gp0SBq9d0FNiJZwVAyERdw++cdDQAsceEHMuU0/4pefG4d/c1lP3J7cTMMT3T1A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.8",
+ "@matter/node": "0.17.8",
+ "@matter/protocol": "0.17.8",
+ "@matter/types": "0.17.8"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.13.0"
+ }
+ },
+ "node_modules/@matter/protocol": {
+ "version": "0.17.8",
+ "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.17.8.tgz",
+ "integrity": "sha512-vXd83JuqnBQ1oNm04NSv5I/yXxl5yek4qQUW+mBfKKFBwx3HR0mEzqWzPXmCYJdxtRDnRDWISlyIYJZU/SlzZQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.8",
+ "@matter/model": "0.17.8",
+ "@matter/types": "0.17.8"
+ }
+ },
+ "node_modules/@matter/types": {
+ "version": "0.17.8",
+ "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.17.8.tgz",
+ "integrity": "sha512-z63hjdOAPbPFHum42zqtuShBJ0ypB/hVVmAjxmYH3sVOu2wDmji8T5GOh0j8MnKQG2DZNEkq/a97IctoVOcyCQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.8",
+ "@matter/model": "0.17.8"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
+ "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.2.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.21.2",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
+ "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/bridge-node/package.json b/bridge-node/package.json
new file mode 100644
index 0000000..606b0b1
--- /dev/null
+++ b/bridge-node/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "indigo-matter-bridge",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Matter bridge node for the indigo-matter Indigo plugin — exports selected Indigo devices as Matter accessories.",
+ "license": "MIT",
+ "type": "module",
+ "main": "dist/main.js",
+ "bin": {
+ "indigo-matter-bridge": "dist/main.js"
+ },
+ "engines": {
+ "node": ">=22.13.0"
+ },
+ "scripts": {
+ "build": "tsc",
+ "start": "node dist/main.js",
+ "test": "tsc -p tsconfig.test.json && rm -rf .test-build/test/fixtures && cp -R test/fixtures .test-build/test/fixtures && node --test \".test-build/test/*.test.js\"",
+ "clean": "rm -rf dist .test-build"
+ },
+ "dependencies": {
+ "@matter/main": "0.17.8",
+ "@matter/nodejs": "0.17.8",
+ "ws": "8.21.2"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.1",
+ "@types/ws": "^8.18.1",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/bridge-node/src/config.ts b/bridge-node/src/config.ts
new file mode 100644
index 0000000..27ff01b
--- /dev/null
+++ b/bridge-node/src/config.ts
@@ -0,0 +1,121 @@
+/**
+ * CLI argument parsing and defaults for the indigo-matter bridge node.
+ *
+ * Every knob the plugin needs to drive is a long-form `--flag value` argument;
+ * launchd plists are easier to read that way than with environment variables.
+ */
+
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+/** Default Matter operational/commissioning port (matter.js's own default). */
+export const DEFAULT_MATTER_PORT = 5540;
+
+/** Default loopback WebSocket port for the plugin protocol (controller uses 5580). */
+export const DEFAULT_WS_PORT = 5581;
+
+/** Storage root, sibling to the controller's directory. See PRD §4.3. */
+export const DEFAULT_STORAGE_PATH = join(
+ homedir(),
+ "Library",
+ "Application Support",
+ "com.simons-plugins.indigo-matter",
+ "bridge-node",
+);
+
+export interface BridgeConfig {
+ /** Directory holding matter.js storage plus our own persisted install identity. */
+ storagePath: string;
+ /** Matter network port. */
+ matterPort: number;
+ /** Loopback port for the plugin protocol WebSocket server. */
+ wsPort: number;
+ /** Optional mDNS interface pin (maps to matter.js's `mdns.networkInterface`). */
+ mdnsInterface?: string;
+}
+
+const USAGE = `indigo-matter-bridge
+
+Usage: indigo-matter-bridge [options]
+
+Options:
+ --storage-path
Storage directory (default: ${DEFAULT_STORAGE_PATH})
+ --matter-port Matter network port (default: ${DEFAULT_MATTER_PORT})
+ --ws-port Loopback protocol WebSocket port (default: ${DEFAULT_WS_PORT})
+ --mdns-interface Pin mDNS to a single network interface (default: all)
+ --help Show this message
+`;
+
+function parsePort(name: string, raw: string): number {
+ const value = Number(raw);
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
+ throw new Error(`${name} must be an integer 1-65535, got ${JSON.stringify(raw)}`);
+ }
+ return value;
+}
+
+/** Flags that take a following value. Checked before "requires a value", so an
+ * unrecognised flag is reported as unknown rather than as a missing value. */
+const VALUE_FLAGS = new Set(["--storage-path", "--matter-port", "--ws-port", "--mdns-interface"]);
+
+/**
+ * Parse `process.argv.slice(2)`. Throws on unknown or malformed arguments — a
+ * bad launchd plist should fail loudly at start rather than silently run with
+ * defaults on the wrong port.
+ */
+export function parseArgs(argv: readonly string[]): BridgeConfig | "help" {
+ const config: BridgeConfig = {
+ storagePath: DEFAULT_STORAGE_PATH,
+ matterPort: DEFAULT_MATTER_PORT,
+ wsPort: DEFAULT_WS_PORT,
+ };
+
+ for (let i = 0; i < argv.length; i++) {
+ const flag = argv[i]!;
+ if (flag === "--help" || flag === "-h") {
+ return "help";
+ }
+ if (!VALUE_FLAGS.has(flag)) {
+ throw new Error(`Unknown argument: ${flag}`);
+ }
+ const value = argv[i + 1];
+ if (value === undefined || value.startsWith("--")) {
+ throw new Error(`${flag} requires a value`);
+ }
+ i++;
+ switch (flag) {
+ case "--storage-path":
+ config.storagePath = value;
+ break;
+ case "--matter-port":
+ config.matterPort = parsePort(flag, value);
+ break;
+ case "--ws-port":
+ config.wsPort = parsePort(flag, value);
+ break;
+ case "--mdns-interface":
+ config.mdnsInterface = value;
+ break;
+ default:
+ throw new Error(`Unknown argument: ${flag}`);
+ }
+ }
+
+ return config;
+}
+
+/**
+ * Fail before start if `--mdns-interface` names something this host does not
+ * have: matter.js takes the name on trust and would advertise on nothing,
+ * which presents as "the bridge never appears" with no error anywhere.
+ */
+export function assertMdnsInterface(name: string, available: readonly string[]): void {
+ if (!available.includes(name)) {
+ throw new Error(
+ `--mdns-interface ${name} is not a network interface on this host ` +
+ `(available: ${available.length > 0 ? available.join(", ") : "none"})`,
+ );
+ }
+}
+
+export { USAGE };
diff --git a/bridge-node/src/main.ts b/bridge-node/src/main.ts
new file mode 100644
index 0000000..8524be3
--- /dev/null
+++ b/bridge-node/src/main.ts
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+/**
+ * indigo-matter bridge node entry point.
+ *
+ * Wires the three pieces together — persisted identity, the Matter ServerNode,
+ * and the loopback protocol server — and shuts them down cleanly on SIGTERM so
+ * launchd restarts are not mistaken for crashes.
+ */
+
+import { createRequire } from "node:module";
+import { networkInterfaces } from "node:os";
+
+import { assertMdnsInterface, parseArgs, USAGE } from "./config.js";
+import { BridgeNode, matterJsVersion } from "./node.js";
+import { describeError, describeErrorWithStack } from "./protocol.js";
+import { loadOrCreateIdentity } from "./storage.js";
+import { BridgeWsServer } from "./ws-server.js";
+
+const bridgeVersion: string = createRequire(import.meta.url)("../package.json").version;
+
+/** Give a stalled shutdown this long before we stop being polite about it. */
+const SHUTDOWN_ESCAPE_MS = 10_000;
+
+function log(message: string): void {
+ // stdout; launchd captures it into the plugin's log file.
+ console.log(`[bridge] ${new Date().toISOString()} ${message}`);
+}
+
+/**
+ * Label each startup step so a launchd log shows *which* one failed. Without
+ * this, "Error: listen EADDRINUSE" could equally be the Matter port or the
+ * protocol port.
+ */
+async function phase(name: string, run: () => Promise | T): Promise {
+ try {
+ return await run();
+ } catch (error) {
+ log(`Startup failed — ${name}: ${describeErrorWithStack(error)}`);
+ throw error;
+ }
+}
+
+async function main(): Promise {
+ let parsed;
+ try {
+ parsed = parseArgs(process.argv.slice(2));
+ } catch (error) {
+ log(`Invalid arguments: ${describeError(error)}`);
+ process.stderr.write(USAGE);
+ process.exit(1);
+ }
+ if (parsed === "help") {
+ process.stdout.write(USAGE);
+ return;
+ }
+ const config = parsed;
+
+ log(`indigo-matter-bridge ${bridgeVersion} (matter.js ${matterJsVersion}, node ${process.version})`);
+
+ if (config.mdnsInterface !== undefined) {
+ await phase("mDNS interface check", () =>
+ assertMdnsInterface(config.mdnsInterface!, Object.keys(networkInterfaces())),
+ );
+ }
+
+ const identity = await phase("identity load failed", () => loadOrCreateIdentity(config.storagePath, log));
+
+ const bridge = new BridgeNode(config, identity, bridgeVersion, log);
+ await phase(`Matter node start failed (matter port ${config.matterPort}, storage ${config.storagePath})`, () =>
+ bridge.start(),
+ );
+
+ const ws = new BridgeWsServer({
+ port: config.wsPort,
+ bridge,
+ bridgeVersion,
+ matterJsVersion,
+ log,
+ });
+ await phase(`protocol WS listen failed (port ${config.wsPort})`, () => ws.listen());
+
+ let shuttingDown = false;
+ const shutdown = (signal: string): void => {
+ if (shuttingDown) {
+ return;
+ }
+ shuttingDown = true;
+ log(`Received ${signal}, shutting down`);
+
+ // Armed before the awaits, so a close that never resolves is still
+ // caught. Unref'd: it must not itself hold the loop open. A forced exit
+ // is not a clean shutdown, hence exit code 1 — launchd should see the
+ // difference. BridgeNode.start() sets `runtime.signals: false`, so
+ // matter.js's ProcessManager is not racing us for these signals.
+ let pending = "protocol WS close";
+ const escapeHatch = setTimeout(() => {
+ log(`Shutdown stalled at: ${pending}; forcing exit`);
+ process.exit(1);
+ }, SHUTDOWN_ESCAPE_MS);
+ escapeHatch.unref();
+
+ void (async () => {
+ // Separate try blocks: a failing WS close must not skip the Matter
+ // close, which is what releases the storage lock.
+ try {
+ await ws.close();
+ } catch (error) {
+ log(`Error closing protocol WS: ${describeErrorWithStack(error)}`);
+ }
+ pending = "Matter node close";
+ try {
+ await bridge.close();
+ } catch (error) {
+ log(`Error closing Matter node: ${describeErrorWithStack(error)}`);
+ }
+ pending = "event loop drain";
+ log("Shutdown complete");
+ })();
+ };
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
+ process.on("SIGINT", () => shutdown("SIGINT"));
+}
+
+// A crash must reach the launchd-captured stdout log with its stack, then exit
+// non-zero so launchd restarts us. Silent survival on a broken invariant is the
+// worse outcome: the plugin would see a live socket and a dead bridge.
+process.on("uncaughtException", (error: unknown) => {
+ log(`Uncaught exception: ${describeErrorWithStack(error)}`);
+ process.exit(1);
+});
+process.on("unhandledRejection", (reason: unknown) => {
+ log(`Unhandled rejection: ${describeErrorWithStack(reason)}`);
+ process.exit(1);
+});
+
+main().catch((error: unknown) => {
+ log(`Fatal: ${describeErrorWithStack(error)}`);
+ // Secondary copy on stderr, for the case where stdout is the thing that broke.
+ console.error(`[bridge] fatal: ${describeErrorWithStack(error)}`);
+ process.exit(1);
+});
diff --git a/bridge-node/src/node.ts b/bridge-node/src/node.ts
new file mode 100644
index 0000000..76e4f41
--- /dev/null
+++ b/bridge-node/src/node.ts
@@ -0,0 +1,347 @@
+/**
+ * The Matter side of the bridge: a ServerNode with an Aggregator at endpoint 1
+ * and (in E0) a single hard-coded bridged child endpoint.
+ *
+ * All matter.js coupling lives here and in main.ts — ADR-0006's binding
+ * constraint keeps it out of the Indigo plugin entirely, and this module keeps
+ * it out of the protocol layer so the protocol is testable on its own.
+ */
+
+import { Endpoint, Environment, ServerNode, VendorId, version as matterJsVersion } from "@matter/main";
+import { BridgedDeviceBasicInformationServer } from "@matter/main/behaviors/bridged-device-basic-information";
+import { OnOffPlugInUnitDevice } from "@matter/main/devices/on-off-plug-in-unit";
+import { AggregatorEndpoint } from "@matter/main/endpoints/aggregator";
+import { Crypto } from "@matter/main";
+import { DeviceCommissioner, PaseClient, PaseServer, SessionManager } from "@matter/main/protocol";
+import { CommissioningFlowType, ManualPairingCodeCodec, QrPairingCodeCodec } from "@matter/main/types";
+
+import type { BridgeConfig } from "./config.js";
+import {
+ type BridgeFacade,
+ type CommissioningWindowResult,
+ describeError,
+ describeErrorWithStack,
+ ErrorCode,
+ type FabricInfo,
+ type PairingReport,
+ ProtocolError,
+ type StatusReport,
+ type WindowClosedReason,
+} from "./protocol.js";
+import { type BridgeIdentity, nodeUniqueIdFor, serialNumberFor } from "./storage.js";
+import { CommissioningWindow } from "./window.js";
+
+/** Test vendor id. Uncertified by design — see ADR-0006 "Attestation cuts both ways". */
+export const VENDOR_ID = 0xfff1;
+export const PRODUCT_ID = 0x8000;
+export const VENDOR_NAME = "simons-plugins";
+export const PRODUCT_NAME = "Indigo Matter Bridge";
+
+/** The one hard-coded export of E0. E1 replaces this with protocol-driven CRUD. */
+const E0_DEVICE_ID = 999001;
+const E0_LABEL = "Indigo E0 Test";
+const E0_ROLE = "onOffPlugInUnit";
+
+/** PBKDF iteration count for enhanced-window verifiers. Spec floor is 1000. */
+const PBKDF_ITERATIONS = 1000;
+const PBKDF_SALT_BYTES = 32;
+
+export { matterJsVersion };
+
+/** Bridged Device Basic Information `UniqueID`, stable across restarts. */
+export function uniqueIdFor(indigoDeviceId: number): string {
+ return `indigo-${indigoDeviceId}`;
+}
+
+/**
+ * `Endpoint.id` derivation — the identity key of BRIDGE_PROTOCOL §4.1/§6.3.
+ * Deliberately the *same* value as {@link uniqueIdFor}: one derivation means the
+ * two can never drift apart, and §6.3's one-way identity flow reads directly.
+ */
+export const endpointIdFor = uniqueIdFor;
+
+export class BridgeNode implements BridgeFacade {
+ #server?: ServerNode;
+ #child?: Endpoint;
+ readonly #window: CommissioningWindow;
+
+ constructor(
+ private readonly config: BridgeConfig,
+ private readonly identity: BridgeIdentity,
+ private readonly bridgeVersion: string,
+ private readonly log: (message: string) => void = console.log,
+ ) {
+ this.#window = new CommissioningWindow({
+ log: message => this.log(message),
+ onExpire: () => this.endMatterCommissioning(),
+ });
+ }
+
+ /** Matter wants an integer software version; derive it from the npm semver. */
+ private get softwareVersion(): number {
+ const [major = 0, minor = 0, patch = 0] = this.bridgeVersion.split(".").map(part => Number.parseInt(part, 10));
+ return (major || 0) * 10000 + (minor || 0) * 100 + (patch || 0);
+ }
+
+ /**
+ * Build the node and bring it online. Aggregator is added first so it takes
+ * endpoint 1; the bridged child then lands at 2.
+ */
+ async start(): Promise {
+ const environment = Environment.default;
+ // matter.js resolves all of these lazily through its VariableService,
+ // so setting them before `ServerNode.create` is what keeps storage out
+ // of ~/.matter and pins mDNS when asked.
+ environment.vars.set("storage.path", this.config.storagePath);
+ if (this.config.mdnsInterface !== undefined) {
+ environment.vars.set("mdns.networkInterface", this.config.mdnsInterface);
+ }
+ // We own SIGTERM/SIGINT outright. matter.js's ProcessManager installs its
+ // own interrupt handlers when the runtime starts, and being registered
+ // first they run first — tearing the ServerNode down concurrently with
+ // main.ts's ordered shutdown. `runtime.signals` is the documented opt-out
+ // (`ProcessManager.hasSignalSupport` reads it).
+ environment.vars.set("runtime.signals", false);
+
+ const server = await ServerNode.create({
+ id: "indigo-matter-bridge",
+ environment,
+ network: { port: this.config.matterPort },
+ commissioning: {
+ passcode: this.identity.passcode,
+ discriminator: this.identity.discriminator,
+ },
+ productDescription: {
+ name: PRODUCT_NAME,
+ deviceType: AggregatorEndpoint.deviceType,
+ },
+ basicInformation: {
+ vendorName: VENDOR_NAME,
+ vendorId: VendorId(VENDOR_ID),
+ productName: PRODUCT_NAME,
+ productId: PRODUCT_ID,
+ serialNumber: serialNumberFor(this.identity),
+ uniqueId: nodeUniqueIdFor(this.identity),
+ hardwareVersion: 1,
+ hardwareVersionString: "1",
+ softwareVersion: this.softwareVersion,
+ softwareVersionString: this.bridgeVersion,
+ },
+ });
+ this.#server = server;
+
+ const aggregator = new Endpoint(AggregatorEndpoint, { id: "aggregator" });
+ await server.add(aggregator);
+
+ const child = new Endpoint(OnOffPlugInUnitDevice.with(BridgedDeviceBasicInformationServer), {
+ id: endpointIdFor(E0_DEVICE_ID),
+ bridgedDeviceBasicInformation: {
+ nodeLabel: E0_LABEL,
+ productName: PRODUCT_NAME,
+ productLabel: E0_LABEL,
+ serialNumber: String(E0_DEVICE_ID),
+ uniqueId: uniqueIdFor(E0_DEVICE_ID),
+ reachable: true,
+ },
+ });
+ await aggregator.add(child);
+ this.#child = child;
+
+ server.events.commissioning.fabricsChanged.on((fabricIndex, action) => {
+ // A throw here propagates straight into matter.js's observable, and
+ // `fabrics()` reads server state that is legitimately gone once
+ // close() is under way. Log-only, never rethrow.
+ try {
+ this.log(`Fabric ${action}: index ${fabricIndex} (${this.fabrics().length} total)`);
+ } catch (error) {
+ try {
+ this.log(`Fabric ${action}: index ${fabricIndex} (count unavailable: ${describeError(error)})`);
+ } catch {
+ // The logger itself failed; there is nowhere left to report.
+ }
+ }
+ });
+
+ await server.start();
+
+ this.log(`Matter node online on port ${this.config.matterPort}, storage ${this.config.storagePath}`);
+ this.logPairing();
+ }
+
+ /** Print the pairing codes the way an operator (and the plugin log) needs them. */
+ logPairing(): void {
+ const pairing = this.getPairing();
+ if (pairing.commissioned && !pairing.windowOpen) {
+ this.log(`Commissioned into ${pairing.fabrics.length} fabric(s); no commissioning window open`);
+ return;
+ }
+ this.log(`Manual pairing code: ${pairing.manualPairingCode}`);
+ this.log(`QR pairing code: ${pairing.qrPairingCode}`);
+ }
+
+ get server(): ServerNode {
+ if (this.#server === undefined) {
+ throw new ProtocolError(ErrorCode.internal, "Matter node not started");
+ }
+ return this.#server;
+ }
+
+ private fabrics(): FabricInfo[] {
+ const fabrics = this.server.state.commissioning.fabrics;
+ return Object.values(fabrics).map(fabric => ({
+ fabricIndex: Number(fabric.fabricIndex),
+ label: fabric.label,
+ vendorId: Number(fabric.rootVendorId),
+ }));
+ }
+
+ getStatus(): StatusReport {
+ const child = this.#child;
+ const endpoints =
+ child === undefined
+ ? []
+ : [{ indigoDeviceId: E0_DEVICE_ID, endpointNumber: Number(child.number), role: E0_ROLE }];
+ return {
+ commissioned: this.server.lifecycle.isCommissioned,
+ fabrics: this.fabrics(),
+ endpointCount: endpoints.length,
+ endpoints,
+ // E6 introduces the persisted endpoint-number allocator; until then
+ // there is no baseline to drift from.
+ drift: [],
+ };
+ }
+
+ /** §5: the sink for `window_closed`, wired up by the protocol server. */
+ onWindowClosed(listener: (reason: WindowClosedReason) => void): void {
+ this.#window.onClosed(listener);
+ }
+
+ getPairing(): PairingReport {
+ const commissioned = this.server.lifecycle.isCommissioned;
+ const window = this.#window.current;
+
+ if (window !== undefined) {
+ return {
+ commissioned,
+ windowOpen: true,
+ windowExpiresAt: window.expiresAt.toISOString(),
+ manualPairingCode: window.manualPairingCode,
+ qrPairingCode: window.qrPairingCode,
+ fabrics: this.fabrics(),
+ };
+ }
+
+ if (!commissioned) {
+ // The never-commissioned initial state: the basic window is open and
+ // its codes are the persisted originals (§3.7).
+ const codes = this.server.state.commissioning.pairingCodes;
+ return {
+ commissioned: false,
+ windowOpen: true,
+ windowExpiresAt: null,
+ manualPairingCode: codes.manualPairingCode,
+ qrPairingCode: codes.qrPairingCode,
+ fabrics: this.fabrics(),
+ };
+ }
+
+ return {
+ commissioned: true,
+ windowOpen: false,
+ windowExpiresAt: null,
+ manualPairingCode: null,
+ qrPairingCode: null,
+ fabrics: this.fabrics(),
+ };
+ }
+
+ /**
+ * Open an enhanced commissioning window so a further ecosystem can pair.
+ * The passcode is ephemeral: matter.js holds only the PAKE verifier, so we
+ * derive and keep the display codes ourselves for the window's lifetime.
+ *
+ * This drives `DeviceCommissioner` rather than the `AdministratorCommissioning`
+ * cluster command, because in matter.js 0.17.8 that command asserts a remote
+ * authenticated session (it records the requesting fabric in
+ * `adminFabricIndex`) and therefore cannot be invoked from an offline agent.
+ * The consequence is that the cluster's `windowStatus`/`adminFabricIndex`
+ * attributes do not reflect a locally-opened window — a conformance gap for
+ * E7 to close, not something E0's pairing flow depends on.
+ */
+ async openCommissioningWindow(durationSeconds: number): Promise {
+ // Before anything Matter-side: `allowEnhancedCommissioning` swaps the PASE
+ // commissioner and only then throws on a double-open, which would kill the
+ // code the user is already holding. See CommissioningWindow.assertClosed.
+ this.#window.assertClosed();
+
+ const server = this.server;
+
+ let discriminator: number;
+ let passcode: number;
+ try {
+ const crypto = server.env.get(Crypto);
+ passcode = PaseClient.generateRandomPasscode(crypto);
+ discriminator = PaseClient.generateRandomDiscriminator(crypto);
+ const salt = crypto.randomBytes(PBKDF_SALT_BYTES);
+ const verifier = await PaseClient.generatePakePasscodeVerifier(crypto, passcode, {
+ iterations: PBKDF_ITERATIONS,
+ salt,
+ });
+ const paseServer = PaseServer.fromVerificationValue(server.env.get(SessionManager), verifier, {
+ iterations: PBKDF_ITERATIONS,
+ salt,
+ });
+ await server.env.get(DeviceCommissioner).allowEnhancedCommissioning(discriminator, paseServer, () => {
+ this.#window.noteEnded();
+ });
+ } catch (error) {
+ this.log(`Failed to open commissioning window: ${describeErrorWithStack(error)}`);
+ throw new ProtocolError(ErrorCode.commissioningWindowFailed, describeError(error));
+ }
+
+ const manualPairingCode = ManualPairingCodeCodec.encode({ discriminator, passcode });
+ const qrPairingCode = QrPairingCodeCodec.encode([
+ {
+ version: 0,
+ vendorId: VENDOR_ID,
+ productId: PRODUCT_ID,
+ flowType: CommissioningFlowType.Standard,
+ discriminator,
+ passcode,
+ discoveryCapabilities: 0b100, // on IP network
+ },
+ ]);
+
+ // DeviceCommissioner *does* build a STANDARD_COMMISSIONING_TIMEOUT timer
+ // in `#enterCommissioningMode`, but never calls `.start()` on it — a
+ // matter.js 0.17.8 bug — so nothing on the Matter side would ever close
+ // this window. Ours is the only timer. If upstream fixes that, the two
+ // race, which is why we cap durationSeconds at Matter's own 900s maximum:
+ // whichever fires first, the outcome is the same.
+ const expiresAt = this.#window.open(durationSeconds, manualPairingCode, qrPairingCode);
+ return { manualPairingCode, qrPairingCode, windowExpiresAt: expiresAt.toISOString() };
+ }
+
+ /** End the Matter-side window after ours expired. Never throws. */
+ private endMatterCommissioning(): void {
+ const server = this.#server;
+ if (server === undefined) {
+ return;
+ }
+ void server.env
+ .get(DeviceCommissioner)
+ .endCommissioning()
+ .catch((error: unknown) => this.log(`Failed to end commissioning: ${describeError(error)}`));
+ }
+
+ async close(): Promise {
+ this.#window.clear();
+ const server = this.#server;
+ this.#server = undefined;
+ this.#child = undefined;
+ if (server !== undefined) {
+ await server.close();
+ }
+ }
+}
diff --git a/bridge-node/src/protocol.ts b/bridge-node/src/protocol.ts
new file mode 100644
index 0000000..90a92f3
--- /dev/null
+++ b/bridge-node/src/protocol.ts
@@ -0,0 +1,165 @@
+/**
+ * Wire types for the plugin ⇄ bridge-node local protocol (BRIDGE_PROTOCOL.md).
+ *
+ * This module is deliberately free of matter.js imports: it is the contract,
+ * and both the WebSocket server and its tests depend on it without dragging in
+ * a Matter stack.
+ */
+
+/** Protocol version this node speaks. Skew fails closed on both peers (§2). */
+export const PROTOCOL_VERSION = 1;
+
+/** Seconds a connection may sit past the handshake without attaching (§2). */
+export const UNATTACHED_TIMEOUT_MS = 10_000;
+
+/**
+ * Enhanced commissioning window bounds, in seconds (§3.8).
+ *
+ * Matter caps `CommissioningTimeout` at 900s and floors it at 180s. The cap is
+ * ours to enforce: matter.js 0.17.8's `DeviceCommissioner` builds a
+ * `STANDARD_COMMISSIONING_TIMEOUT` timer but never starts it, so nothing on the
+ * Matter side would close a longer window.
+ */
+export const WINDOW_DURATION_MIN_SECONDS = 180;
+export const WINDOW_DURATION_MAX_SECONDS = 900;
+export const WINDOW_DURATION_DEFAULT_SECONDS = 900;
+
+/** The complete `error_code` domain for protocol version 1 (§1.1). */
+export const ErrorCode = {
+ unknownCommand: "unknown_command",
+ malformedArgs: "malformed_args",
+ versionMismatch: "version_mismatch",
+ notAttached: "not_attached",
+ unknownDevice: "unknown_device",
+ unknownRole: "unknown_role",
+ roleChange: "role_change",
+ massRemovalRefused: "mass_removal_refused",
+ endpointMapInvalid: "endpoint_map_invalid",
+ commissioningWindowFailed: "commissioning_window_failed",
+ internal: "internal",
+} as const;
+
+export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
+
+/** The bare frame the node sends on every new connection, before `attach` (§2). */
+export interface HandshakeFrame {
+ protocolVersion: number;
+ bridgeVersion: string;
+ matterJsVersion: string;
+}
+
+/** plugin → node (§1). */
+export interface RequestFrame {
+ message_id: string;
+ command: string;
+ args?: Record;
+}
+
+/** node → plugin, success (§1). */
+export interface SuccessFrame {
+ message_id: string;
+ result: unknown;
+}
+
+/** node → plugin, failure (§1). */
+export interface ErrorFrame {
+ message_id: string;
+ error_code: ErrorCodeValue;
+ details: string;
+}
+
+/** node → plugin, unsolicited (§1). */
+export interface EventFrame {
+ event: string;
+ data: Record;
+}
+
+/** §5 `window_closed` — why the enhanced commissioning window ended. */
+export type WindowClosedReason = "expired" | "commissioned";
+
+/** §5 event names emitted in E0. The rest arrive with endpoint CRUD in E1. */
+export const EventName = {
+ windowClosed: "window_closed",
+} as const;
+
+/** §4.3 */
+export interface FabricInfo {
+ fabricIndex: number;
+ label: string;
+ vendorId: number;
+}
+
+/** §4.3 — the E0 subset; `drift` is always empty until E6 adds the allocator. */
+export interface StatusReport {
+ commissioned: boolean;
+ fabrics: FabricInfo[];
+ endpointCount: number;
+ endpoints: EndpointSummary[];
+ drift: DriftEntry[];
+}
+
+export interface EndpointSummary {
+ indigoDeviceId: number;
+ endpointNumber: number;
+ role: string;
+}
+
+export interface DriftEntry {
+ uniqueId: string;
+ expected: number;
+ actual: number;
+}
+
+/** §3.7 */
+export interface PairingReport {
+ commissioned: boolean;
+ windowOpen: boolean;
+ windowExpiresAt: string | null;
+ manualPairingCode: string | null;
+ qrPairingCode: string | null;
+ fabrics: FabricInfo[];
+}
+
+/** §3.8 */
+export interface CommissioningWindowResult {
+ manualPairingCode: string;
+ qrPairingCode: string;
+ windowExpiresAt: string;
+}
+
+/**
+ * The slice of bridge behaviour the protocol server needs. Keeping it an
+ * interface is what lets the protocol tests run without a live Matter stack.
+ */
+export interface BridgeFacade {
+ getStatus(): StatusReport;
+ getPairing(): PairingReport;
+ openCommissioningWindow(durationSeconds: number): Promise;
+ /**
+ * Register the sink for `window_closed` (§3.8/§5). One listener, last
+ * registration wins — this seam is what lets the protocol server emit the
+ * event without importing the Matter stack, and lets tests fire it.
+ */
+ onWindowClosed(listener: (reason: WindowClosedReason) => void): void;
+}
+
+/** A protocol-level failure a command handler can throw to shape its response. */
+export class ProtocolError extends Error {
+ constructor(
+ readonly code: ErrorCodeValue,
+ details: string,
+ ) {
+ super(details);
+ this.name = "ProtocolError";
+ }
+}
+
+/** The human-readable message for an unknown throwable — what `details` carries. */
+export function describeError(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+/** The same, with a stack when there is one — for the log, never for the wire. */
+export function describeErrorWithStack(error: unknown): string {
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
+}
diff --git a/bridge-node/src/storage.ts b/bridge-node/src/storage.ts
new file mode 100644
index 0000000..14a20a2
--- /dev/null
+++ b/bridge-node/src/storage.ts
@@ -0,0 +1,167 @@
+/**
+ * Persisted bridge identity.
+ *
+ * The install id, commissioning passcode and discriminator are generated once
+ * and kept in `identity.json` **outside** matter.js's own storage context, so a
+ * future factory reset (which wipes that context) cannot silently change the
+ * serial number a paired ecosystem remembers us by.
+ *
+ * Stdlib only — no matter.js import — so this is cheap to test.
+ */
+
+import { randomInt, randomUUID } from "node:crypto";
+import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describeError } from "./protocol.js";
+
+/** Inclusive passcode bounds. Matter core spec §5.1.1.6. */
+export const PASSCODE_MIN = 1;
+export const PASSCODE_MAX = 99_999_998;
+
+/**
+ * Trivial passcodes that must never be used. Matter core spec §5.1.7.1.
+ * `0` is listed there too and is outside {@link PASSCODE_MIN}, but a persisted
+ * file can still carry it, so it is named here rather than left to the bounds.
+ */
+export const INVALID_PASSCODES: readonly number[] = [
+ 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, 99999999, 12345678, 87654321,
+];
+
+/** The discriminator is 12 bits. */
+export const DISCRIMINATOR_MAX = 0xfff;
+
+const IDENTITY_FILE = "identity.json";
+
+export interface BridgeIdentity {
+ /** Stable install id; seeds `serialNumber` and `uniqueId`. */
+ installId: string;
+ /** Matter setup passcode for the initial (basic) commissioning window. */
+ passcode: number;
+ /** 12-bit commissioning discriminator. */
+ discriminator: number;
+}
+
+/** True for a passcode inside the legal range and not on the trivial list. */
+export function isValidPasscode(passcode: number): boolean {
+ if (!Number.isInteger(passcode) || passcode < PASSCODE_MIN || passcode > PASSCODE_MAX) {
+ return false;
+ }
+ return !INVALID_PASSCODES.includes(passcode);
+}
+
+/** A cryptographically random passcode that satisfies {@link isValidPasscode}. */
+export function generatePasscode(): number {
+ for (;;) {
+ const candidate = randomInt(PASSCODE_MIN, PASSCODE_MAX + 1);
+ if (isValidPasscode(candidate)) {
+ return candidate;
+ }
+ }
+}
+
+/** A cryptographically random 12-bit discriminator. */
+export function generateDiscriminator(): number {
+ return randomInt(0, DISCRIMINATOR_MAX + 1);
+}
+
+function isUsableIdentity(value: unknown): value is BridgeIdentity {
+ if (typeof value !== "object" || value === null) {
+ return false;
+ }
+ const candidate = value as Partial;
+ return (
+ typeof candidate.installId === "string" &&
+ candidate.installId.length > 0 &&
+ typeof candidate.passcode === "number" &&
+ isValidPasscode(candidate.passcode) &&
+ typeof candidate.discriminator === "number" &&
+ Number.isInteger(candidate.discriminator) &&
+ candidate.discriminator >= 0 &&
+ candidate.discriminator <= DISCRIMINATOR_MAX
+ );
+}
+
+function isNotFound(error: unknown): boolean {
+ return (error as NodeJS.ErrnoException | null)?.code === "ENOENT";
+}
+
+/**
+ * Write `identity.json` atomically: a temp file in the *same* directory (so the
+ * rename stays within one filesystem and is therefore atomic), then `rename`.
+ * A crash mid-write must never leave a half-written identity behind, because a
+ * half-written identity reads as corrupt and gets regenerated.
+ */
+function writeIdentity(file: string, identity: BridgeIdentity): void {
+ const temp = `${file}.${process.pid}.tmp`;
+ try {
+ writeFileSync(temp, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
+ renameSync(temp, file);
+ } catch (error) {
+ try {
+ unlinkSync(temp);
+ } catch {
+ // Best effort: the temp file may never have been created.
+ }
+ throw error;
+ }
+}
+
+/**
+ * Read `identity.json` from {@link storagePath}, creating it (and the directory)
+ * with freshly randomised values on first run. An unreadable or invalid file is
+ * replaced — a bridge that cannot advertise is worse than one that needs
+ * re-pairing, and E0 has nothing paired to protect yet.
+ *
+ * TODO(E1): a corrupt-but-present identity must refuse to start (PRD §4.3) —
+ * regeneration un-pairs every ecosystem. Only the missing-file branch may mint.
+ */
+export function loadOrCreateIdentity(
+ storagePath: string,
+ log: (message: string) => void = () => {},
+): BridgeIdentity {
+ mkdirSync(storagePath, { recursive: true });
+ const file = join(storagePath, IDENTITY_FILE);
+
+ // `undefined` means "no file at all"; anything else is why we are replacing
+ // a file that does exist — the distinction E1 turns into refuse-to-start.
+ let problem: string | undefined;
+ try {
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
+ if (isUsableIdentity(parsed)) {
+ log(`Loaded bridge identity from ${file}`);
+ return parsed;
+ }
+ problem = "present but not a usable identity (missing or out-of-range fields)";
+ } catch (error) {
+ problem = isNotFound(error) ? undefined : `unreadable (${describeError(error)})`;
+ }
+
+ if (problem === undefined) {
+ log(`No identity at ${file}; minting new bridge identity`);
+ } else {
+ log(`Replacing bridge identity at ${file}: ${problem}`);
+ }
+
+ const identity: BridgeIdentity = {
+ installId: randomUUID(),
+ passcode: generatePasscode(),
+ discriminator: generateDiscriminator(),
+ };
+ writeIdentity(file, identity);
+ return identity;
+}
+
+/**
+ * Matter `SerialNumber` is capped at 32 characters, so the UUID is used with its
+ * dashes stripped. Matter requires `UniqueID` and `SerialNumber` to differ, so
+ * {@link uniqueIdFor} takes the full value and this takes the first half.
+ */
+export function serialNumberFor(identity: BridgeIdentity): string {
+ return identity.installId.replace(/-/g, "").slice(0, 16);
+}
+
+/** The root node's Matter `UniqueID` — distinct from {@link serialNumberFor}. */
+export function nodeUniqueIdFor(identity: BridgeIdentity): string {
+ return identity.installId.replace(/-/g, "").slice(0, 32);
+}
diff --git a/bridge-node/src/window.ts b/bridge-node/src/window.ts
new file mode 100644
index 0000000..ab60b43
--- /dev/null
+++ b/bridge-node/src/window.ts
@@ -0,0 +1,141 @@
+/**
+ * The enhanced-commissioning-window state machine (§3.8).
+ *
+ * Extracted from {@link BridgeNode} so the bookkeeping — double-open refusal,
+ * expiry, and which reason a `window_closed` event carries — is unit-testable
+ * without a Matter stack. Timer and clock are injected for the same reason.
+ */
+
+import { describeError, ErrorCode, ProtocolError, type WindowClosedReason } from "./protocol.js";
+
+/** Cancels a scheduled expiry. */
+export type Cancel = () => void;
+
+/** Schedules `callback` `ms` from now and returns its canceller. */
+export type Scheduler = (callback: () => void, ms: number) => Cancel;
+
+/** Real timers, unref'd: an open window must never keep the process alive. */
+export const defaultScheduler: Scheduler = (callback, ms) => {
+ const timer = setTimeout(callback, ms);
+ timer.unref?.();
+ return () => clearTimeout(timer);
+};
+
+export interface OpenWindow {
+ expiresAt: Date;
+ manualPairingCode: string;
+ qrPairingCode: string;
+}
+
+export interface CommissioningWindowOptions {
+ schedule?: Scheduler;
+ now?: () => number;
+ log?: (message: string) => void;
+ /**
+ * Ends the Matter-side window when our expiry timer fires first. Anything
+ * it throws is caught and logged — a timer callback must never rethrow.
+ */
+ onExpire?: () => void;
+}
+
+export class CommissioningWindow {
+ #open?: OpenWindow;
+ #cancel?: Cancel;
+ #listener?: (reason: WindowClosedReason) => void;
+ readonly #schedule: Scheduler;
+ readonly #now: () => number;
+ readonly #log: (message: string) => void;
+ readonly #onExpire: (() => void) | undefined;
+
+ constructor(options: CommissioningWindowOptions = {}) {
+ this.#schedule = options.schedule ?? defaultScheduler;
+ this.#now = options.now ?? Date.now;
+ this.#log = options.log ?? (() => {});
+ this.#onExpire = options.onExpire;
+ }
+
+ /** Register the `window_closed` sink (§5). Last registration wins. */
+ onClosed(listener: (reason: WindowClosedReason) => void): void {
+ this.#listener = listener;
+ }
+
+ get isOpen(): boolean {
+ return this.#open !== undefined;
+ }
+
+ /** The live window, or `undefined` when none is open. */
+ get current(): Readonly | undefined {
+ return this.#open;
+ }
+
+ /**
+ * Refuse a second window *before* anything Matter-side is touched.
+ *
+ * matter.js 0.17.8's `DeviceCommissioner.allowEnhancedCommissioning` installs
+ * the new PASE commissioner and only then throws on the double-open, so a
+ * refusal that reaches the stack has already invalidated the code the user
+ * is holding. Guarding here keeps the open window intact.
+ */
+ assertClosed(): void {
+ if (this.#open !== undefined) {
+ throw new ProtocolError(ErrorCode.commissioningWindowFailed, "A commissioning window is already open");
+ }
+ }
+
+ /** Record a freshly opened window and arm its expiry. */
+ open(durationSeconds: number, manualPairingCode: string, qrPairingCode: string): Date {
+ this.assertClosed();
+ const expiresAt = new Date(this.#now() + durationSeconds * 1000);
+ this.#open = { expiresAt, manualPairingCode, qrPairingCode };
+ this.#cancel = this.#schedule(() => this.#expire(), durationSeconds * 1000);
+ return expiresAt;
+ }
+
+ /**
+ * matter.js reports its window ended. Returns `false` when we had already
+ * closed ours — which is what keeps expiry from announcing twice, since the
+ * expiry path itself calls `endCommissioning()` and that fires this back.
+ */
+ noteEnded(): boolean {
+ if (this.#open === undefined) {
+ return false;
+ }
+ this.#discard();
+ this.#log("Commissioning window closed by commissioner");
+ this.#emit("commissioned");
+ return true;
+ }
+
+ /** Shutdown: drop the window without announcing a close. */
+ clear(): void {
+ this.#discard();
+ }
+
+ #expire(): void {
+ if (this.#open === undefined) {
+ return;
+ }
+ this.#discard();
+ this.#log("Commissioning window expired");
+ this.#emit("expired");
+ try {
+ this.#onExpire?.();
+ } catch (error) {
+ this.#log(`Failed to end Matter commissioning after expiry: ${describeError(error)}`);
+ }
+ }
+
+ #discard(): void {
+ this.#cancel?.();
+ this.#cancel = undefined;
+ this.#open = undefined;
+ }
+
+ #emit(reason: WindowClosedReason): void {
+ try {
+ this.#listener?.(reason);
+ } catch (error) {
+ this.#log(`window_closed(${reason}) listener failed: ${describeError(error)}`);
+ }
+ }
+}
diff --git a/bridge-node/src/ws-server.ts b/bridge-node/src/ws-server.ts
new file mode 100644
index 0000000..4e47232
--- /dev/null
+++ b/bridge-node/src/ws-server.ts
@@ -0,0 +1,308 @@
+/**
+ * The plugin ⇄ bridge-node WebSocket server (BRIDGE_PROTOCOL.md).
+ *
+ * Loopback only: there is no auth on this socket because there is no remote
+ * surface (§6.1). No matter.js import here — the server talks to a
+ * {@link BridgeFacade}, which is what makes the protocol testable without a
+ * live Matter stack.
+ */
+
+import { WebSocketServer, type WebSocket } from "ws";
+
+import {
+ type BridgeFacade,
+ describeError,
+ describeErrorWithStack,
+ ErrorCode,
+ type ErrorCodeValue,
+ EventName,
+ type EventFrame,
+ type HandshakeFrame,
+ PROTOCOL_VERSION,
+ ProtocolError,
+ UNATTACHED_TIMEOUT_MS,
+ WINDOW_DURATION_DEFAULT_SECONDS,
+ WINDOW_DURATION_MAX_SECONDS,
+ WINDOW_DURATION_MIN_SECONDS,
+} from "./protocol.js";
+
+export const LOOPBACK_HOST = "127.0.0.1";
+
+export interface BridgeWsServerOptions {
+ port: number;
+ bridge: BridgeFacade;
+ bridgeVersion: string;
+ matterJsVersion: string;
+ log?: (message: string) => void;
+ /** Overridable so the §2 timeout is testable without a 10s wait. */
+ unattachedTimeoutMs?: number;
+}
+
+interface ClientState {
+ attached: boolean;
+ unattachedTimer?: NodeJS.Timeout;
+ /**
+ * Tail of this socket's handler chain. §1 promises frames are processed in
+ * receipt order, and handlers genuinely await (`open_commissioning_window`
+ * does crypto), so each frame is queued behind the previous one rather than
+ * dispatched concurrently.
+ */
+ pending: Promise;
+}
+
+/** Commands whose handlers exist in E0. Endpoint CRUD arrives in E1. */
+type CommandHandler = (args: Record, socket: WebSocket, state: ClientState) => Promise;
+
+export class BridgeWsServer {
+ #wss?: WebSocketServer;
+ #attached?: WebSocket;
+ readonly #clients = new Map();
+ readonly #handlers = new Map();
+ readonly #log: (message: string) => void;
+
+ constructor(private readonly options: BridgeWsServerOptions) {
+ this.#log = options.log ?? console.log;
+ this.#handlers.set("attach", (args, socket, state) => this.handleAttach(args, socket, state));
+ this.#handlers.set("get_status", async () => this.options.bridge.getStatus());
+ this.#handlers.set("get_pairing", async () => this.options.bridge.getPairing());
+ this.#handlers.set("open_commissioning_window", async args => this.handleOpenWindow(args));
+ options.bridge.onWindowClosed(reason => this.sendEvent(EventName.windowClosed, { reason }));
+ }
+
+ /**
+ * Push an unsolicited event (§1/§5) to the attached client. Dropped silently
+ * when nobody is attached: the plugin re-`attach`es on reconnect and gets a
+ * full reconcile, so a missed event has nothing to recover.
+ */
+ sendEvent(event: string, data: Record): void {
+ const socket = this.#attached;
+ if (socket === undefined) {
+ return;
+ }
+ const frame: EventFrame = { event, data };
+ this.send(socket, frame);
+ }
+
+ /** Bind and start accepting connections. Resolves once listening. */
+ async listen(): Promise {
+ await new Promise((resolve, reject) => {
+ const wss = new WebSocketServer({ host: LOOPBACK_HOST, port: this.options.port });
+ wss.once("error", reject);
+ wss.once("listening", () => {
+ wss.off("error", reject);
+ wss.on("error", error => this.#log(`WebSocket server error: ${describeError(error)}`));
+ resolve();
+ });
+ wss.on("connection", socket => this.onConnection(socket));
+ this.#wss = wss;
+ });
+ this.#log(`Protocol WebSocket listening on ws://${LOOPBACK_HOST}:${this.options.port}`);
+ }
+
+ /** The bound port — useful when a test asks for port 0. */
+ get port(): number {
+ const address = this.#wss?.address();
+ if (address === undefined || address === null || typeof address === "string") {
+ return this.options.port;
+ }
+ return address.port;
+ }
+
+ async close(): Promise {
+ const wss = this.#wss;
+ this.#wss = undefined;
+ for (const [socket, state] of this.#clients) {
+ clearTimeout(state.unattachedTimer);
+ // `terminate`, not `close`: a graceful close waits for the peer's
+ // close frame, and shutdown must not be held hostage by a plugin
+ // that is itself mid-crash. `wss.close()` below then resolves.
+ socket.terminate();
+ }
+ this.#clients.clear();
+ this.#attached = undefined;
+ if (wss !== undefined) {
+ await new Promise(resolve => wss.close(() => resolve()));
+ }
+ }
+
+ private onConnection(socket: WebSocket): void {
+ const state: ClientState = { attached: false, pending: Promise.resolve() };
+ this.#clients.set(socket, state);
+
+ // Before the handshake send: `ws` throws an unhandled 'error' event if a
+ // socket fails with no listener attached, and a socket can fail on the
+ // very first write.
+ socket.on("error", error => this.#log(`Socket error: ${describeError(error)}`));
+
+ // §2 step 1: the bare handshake frame, before anything else.
+ const handshake: HandshakeFrame = {
+ protocolVersion: PROTOCOL_VERSION,
+ bridgeVersion: this.options.bridgeVersion,
+ matterJsVersion: this.options.matterJsVersion,
+ };
+ socket.send(JSON.stringify(handshake));
+
+ // §2: a connection that handshakes but never attaches is closed.
+ const timeoutMs = this.options.unattachedTimeoutMs ?? UNATTACHED_TIMEOUT_MS;
+ state.unattachedTimer = setTimeout(() => {
+ if (!state.attached) {
+ this.#log(`Closing connection that did not attach within ${timeoutMs}ms`);
+ socket.close();
+ }
+ }, timeoutMs);
+ state.unattachedTimer.unref?.();
+
+ socket.on("message", (data, isBinary) => {
+ if (isBinary) {
+ // The protocol is JSON text frames only (§1); nothing to answer.
+ this.#log("Dropping binary frame");
+ return;
+ }
+ const raw = data.toString();
+ // §1: strictly in receipt order — chain, never fan out. The tail
+ // catch also guarantees the chain is never left rejected, so one bad
+ // frame cannot stall every frame behind it.
+ state.pending = state.pending
+ .then(() => this.onMessage(socket, state, raw))
+ .catch(error => this.#log(`Frame handling failed: ${describeErrorWithStack(error)}`));
+ });
+ socket.on("close", () => {
+ clearTimeout(state.unattachedTimer);
+ this.#clients.delete(socket);
+ if (this.#attached === socket) {
+ this.#attached = undefined;
+ this.#log("Attached client disconnected");
+ }
+ });
+ }
+
+ private async onMessage(socket: WebSocket, state: ClientState, raw: string): Promise {
+ let frame: unknown;
+ try {
+ frame = JSON.parse(raw);
+ } catch {
+ // Unaddressable: no message_id to echo, so there is nobody to answer.
+ this.#log("Dropping unparseable frame");
+ return;
+ }
+
+ if (typeof frame !== "object" || frame === null) {
+ this.#log("Dropping non-object frame");
+ return;
+ }
+
+ const { message_id: messageId, command, args } = frame as Record;
+ if (typeof messageId !== "string") {
+ this.#log("Dropping frame without a message_id");
+ return;
+ }
+ if (typeof command !== "string") {
+ this.send(socket, { message_id: messageId, error_code: ErrorCode.malformedArgs, details: "Missing command" });
+ return;
+ }
+
+ const commandArgs: Record =
+ typeof args === "object" && args !== null ? (args as Record) : {};
+
+ // Gating first: before `attach` the node has said nothing about which
+ // commands it knows, so `not_attached` is the honest answer even for a
+ // name it would otherwise reject (§1.1).
+ if (command !== "attach" && !state.attached) {
+ this.sendError(socket, messageId, ErrorCode.notAttached, `${command} requires a successful attach first`);
+ return;
+ }
+
+ const handler = this.#handlers.get(command);
+ if (handler === undefined) {
+ this.sendError(socket, messageId, ErrorCode.unknownCommand, `Unknown command ${command}`);
+ return;
+ }
+
+ try {
+ const result = await handler(commandArgs, socket, state);
+ this.send(socket, { message_id: messageId, result });
+ } catch (error) {
+ if (error instanceof ProtocolError) {
+ this.sendError(socket, messageId, error.code, error.message);
+ // §2: version skew fails closed — answer, then hang up.
+ if (error.code === ErrorCode.versionMismatch) {
+ socket.close();
+ }
+ return;
+ }
+ // Stack node-side, message on the wire: `details` is what the Indigo
+ // log shows a user, the stack is what we need to debug the node.
+ this.#log(`Command ${command} failed: ${describeErrorWithStack(error)}`);
+ this.sendError(socket, messageId, ErrorCode.internal, describeError(error));
+ }
+ }
+
+ private async handleAttach(
+ args: Record,
+ socket: WebSocket,
+ state: ClientState,
+ ): Promise {
+ const version = args.protocolVersion;
+ if (typeof version !== "number") {
+ throw new ProtocolError(ErrorCode.malformedArgs, "attach requires a numeric protocolVersion");
+ }
+ if (version !== PROTOCOL_VERSION) {
+ throw new ProtocolError(
+ ErrorCode.versionMismatch,
+ `Node speaks protocol version ${PROTOCOL_VERSION}, client sent ${version}`,
+ );
+ }
+
+ // §2: exactly one attached client; a new attach supersedes the incumbent,
+ // which is how we recover from a half-open socket left by a plugin crash.
+ const incumbent = this.#attached;
+ if (incumbent !== undefined && incumbent !== socket) {
+ this.#log("Superseding previously attached client");
+ incumbent.close();
+ const incumbentState = this.#clients.get(incumbent);
+ if (incumbentState !== undefined) {
+ incumbentState.attached = false;
+ }
+ }
+
+ state.attached = true;
+ clearTimeout(state.unattachedTimer);
+ state.unattachedTimer = undefined;
+ this.#attached = socket;
+
+ const pluginVersion = typeof args.pluginVersion === "string" ? args.pluginVersion : "unknown";
+ this.#log(`Client attached (plugin ${pluginVersion})`);
+
+ // E1 reconciles `args.endpoints` here; E0 serves a fixed endpoint set.
+ // The §3.1 mass-removal guard (`mass_removal_refused` unless
+ // `intent: "replace_all"`) belongs with that reconcile — it is E1 scope,
+ // and until then there is no client-supplied set that could empty.
+ return this.options.bridge.getStatus();
+ }
+
+ private async handleOpenWindow(args: Record): Promise {
+ const duration = args.durationSeconds ?? WINDOW_DURATION_DEFAULT_SECONDS;
+ if (
+ typeof duration !== "number" ||
+ !Number.isInteger(duration) ||
+ duration < WINDOW_DURATION_MIN_SECONDS ||
+ duration > WINDOW_DURATION_MAX_SECONDS
+ ) {
+ throw new ProtocolError(
+ ErrorCode.malformedArgs,
+ `durationSeconds must be an integer ${WINDOW_DURATION_MIN_SECONDS}-${WINDOW_DURATION_MAX_SECONDS}`,
+ );
+ }
+ return this.options.bridge.openCommissioningWindow(duration);
+ }
+
+ private sendError(socket: WebSocket, messageId: string, code: ErrorCodeValue, details: string): void {
+ this.send(socket, { message_id: messageId, error_code: code, details });
+ }
+
+ private send(socket: WebSocket, frame: unknown): void {
+ if (socket.readyState === socket.OPEN) {
+ socket.send(JSON.stringify(frame));
+ }
+ }
+}
diff --git a/bridge-node/test/client.ts b/bridge-node/test/client.ts
new file mode 100644
index 0000000..e4d5e7f
--- /dev/null
+++ b/bridge-node/test/client.ts
@@ -0,0 +1,98 @@
+/**
+ * Minimal test client: a WebSocket wrapped in a frame queue so tests can await
+ * one frame at a time in receipt order (§1 guarantees ordering).
+ */
+
+import { WebSocket } from "ws";
+
+export class TestClient {
+ readonly #socket: WebSocket;
+ readonly #frames: Record[] = [];
+ #waiter?: (frame: Record) => void;
+ closed = false;
+
+ private constructor(socket: WebSocket) {
+ this.#socket = socket;
+ socket.on("message", data => {
+ const frame = JSON.parse(data.toString()) as Record;
+ const waiter = this.#waiter;
+ if (waiter !== undefined) {
+ this.#waiter = undefined;
+ waiter(frame);
+ } else {
+ this.#frames.push(frame);
+ }
+ });
+ socket.on("close", () => {
+ this.closed = true;
+ });
+ }
+
+ static async connect(port: number): Promise {
+ const socket = new WebSocket(`ws://127.0.0.1:${port}`);
+ // Listeners must be attached before `open` resolves: the node sends the
+ // handshake the instant it accepts, and `ws` drops messages emitted
+ // before a listener exists.
+ const client = new TestClient(socket);
+ await new Promise((resolve, reject) => {
+ socket.once("open", resolve);
+ socket.once("error", reject);
+ });
+ return client;
+ }
+
+ /** Next frame, or a rejection after {@link timeoutMs}. */
+ async next(timeoutMs = 2000): Promise> {
+ const buffered = this.#frames.shift();
+ if (buffered !== undefined) {
+ return buffered;
+ }
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.#waiter = undefined;
+ reject(new Error("Timed out waiting for a frame"));
+ }, timeoutMs);
+ this.#waiter = frame => {
+ clearTimeout(timer);
+ resolve(frame);
+ };
+ });
+ }
+
+ send(frame: unknown): void {
+ this.#socket.send(JSON.stringify(frame));
+ }
+
+ /** Bypass JSON.stringify — for the garbage-frame tests. */
+ sendRaw(payload: string | Buffer): void {
+ this.#socket.send(payload);
+ }
+
+ /** True once no more frames are buffered — used to assert "nothing came back". */
+ get buffered(): number {
+ return this.#frames.length;
+ }
+
+ /** Send a request and return its response frame. */
+ async request(frame: unknown): Promise> {
+ this.send(frame);
+ return this.next();
+ }
+
+ async waitForClose(timeoutMs = 2000): Promise {
+ if (this.closed) {
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("Socket did not close")), timeoutMs);
+ this.#socket.once("close", () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+ }
+
+ close(): void {
+ this.#socket.close();
+ }
+}
diff --git a/bridge-node/test/config.test.ts b/bridge-node/test/config.test.ts
new file mode 100644
index 0000000..f7ca38d
--- /dev/null
+++ b/bridge-node/test/config.test.ts
@@ -0,0 +1,87 @@
+/**
+ * CLI argument parsing — a bad launchd plist must fail loudly, not run on the
+ * wrong port with defaults.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import {
+ assertMdnsInterface,
+ DEFAULT_MATTER_PORT,
+ DEFAULT_STORAGE_PATH,
+ DEFAULT_WS_PORT,
+ parseArgs,
+} from "../src/config.js";
+
+describe("parseArgs", () => {
+ it("defaults to 5540 / 5581 and the Application Support storage path", () => {
+ const config = parseArgs([]);
+ assert.notEqual(config, "help");
+ assert.deepEqual(config, {
+ storagePath: DEFAULT_STORAGE_PATH,
+ matterPort: DEFAULT_MATTER_PORT,
+ wsPort: DEFAULT_WS_PORT,
+ });
+ assert.equal(DEFAULT_MATTER_PORT, 5540);
+ assert.equal(DEFAULT_WS_PORT, 5581);
+ assert.ok(DEFAULT_STORAGE_PATH.endsWith("com.simons-plugins.indigo-matter/bridge-node"));
+ assert.ok(!DEFAULT_STORAGE_PATH.includes(".matter"));
+ });
+
+ it("reads every supported flag", () => {
+ const config = parseArgs([
+ "--storage-path",
+ "/tmp/bridge",
+ "--matter-port",
+ "5541",
+ "--ws-port",
+ "5582",
+ "--mdns-interface",
+ "en0",
+ ]);
+ assert.deepEqual(config, {
+ storagePath: "/tmp/bridge",
+ matterPort: 5541,
+ wsPort: 5582,
+ mdnsInterface: "en0",
+ });
+ });
+
+ it("leaves mdnsInterface unset when not given", () => {
+ const config = parseArgs([]);
+ assert.equal((config as { mdnsInterface?: string }).mdnsInterface, undefined);
+ });
+
+ it("rejects unknown flags, missing values and bad ports", () => {
+ assert.throws(() => parseArgs(["--nope", "x"]), /Unknown argument: --nope/);
+ assert.throws(() => parseArgs(["--ws-port"]), /requires a value/);
+ assert.throws(() => parseArgs(["--ws-port", "0"]), /must be an integer/);
+ assert.throws(() => parseArgs(["--matter-port", "abc"]), /must be an integer/);
+ });
+
+ it("names an unknown flag as unknown, not as missing a value", () => {
+ // The known-flag check comes first, so `--bogus` with nothing after it
+ // is still reported as unknown — the message a bad plist needs.
+ assert.throws(() => parseArgs(["--bogus"]), /Unknown argument: --bogus/);
+ assert.throws(() => parseArgs(["--bogus", "--ws-port", "5581"]), /Unknown argument: --bogus/);
+ assert.throws(() => parseArgs(["--ws-port", "5581", "--bogus"]), /Unknown argument: --bogus/);
+ assert.throws(() => parseArgs(["stray"]), /Unknown argument: stray/);
+ });
+
+ it("returns help for --help", () => {
+ assert.equal(parseArgs(["--help"]), "help");
+ });
+});
+
+describe("assertMdnsInterface", () => {
+ it("accepts an interface the host actually has", () => {
+ assert.doesNotThrow(() => assertMdnsInterface("en0", ["lo0", "en0", "en1"]));
+ });
+
+ it("names the missing interface and lists what is available", () => {
+ assert.throws(() => assertMdnsInterface("en9", ["lo0", "en0"]), /--mdns-interface en9 is not a network interface/);
+ assert.throws(() => assertMdnsInterface("en9", ["lo0", "en0"]), /available: lo0, en0/);
+ assert.throws(() => assertMdnsInterface("en9", []), /available: none/);
+ });
+});
diff --git a/bridge-node/test/fixture-shapes.ts b/bridge-node/test/fixture-shapes.ts
new file mode 100644
index 0000000..8e151b7
--- /dev/null
+++ b/bridge-node/test/fixture-shapes.ts
@@ -0,0 +1,101 @@
+/**
+ * Compile-time mirror of `fixtures/e0-frames.json` (§7 testing contract).
+ *
+ * `JSON.parse` erases types, so the golden file alone cannot fail `tsc` when a
+ * shape in `protocol.ts` drifts. Each payload is restated here bound with
+ * `satisfies` to its protocol type; `fixtures.test.ts` then deep-equals mirror
+ * against JSON. Between the two, a shape change has to be made in three places
+ * on purpose or something goes red:
+ *
+ * - rename a field in `protocol.ts` → this module fails to compile
+ * - edit the JSON without the mirror → `fixtures.test.ts` fails
+ * - edit the mirror without the JSON → `fixtures.test.ts` fails
+ */
+
+import type {
+ CommissioningWindowResult,
+ ErrorFrame,
+ EventFrame,
+ HandshakeFrame,
+ PairingReport,
+ StatusReport,
+} from "../src/protocol.js";
+
+/** Versions are placeholders: the real ones track package.json / matter.js. */
+export const handshake = {
+ protocolVersion: 1,
+ bridgeVersion: "0.0.0-test",
+ matterJsVersion: "0.0.0-test",
+} satisfies HandshakeFrame;
+
+export const status = {
+ commissioned: false,
+ fabrics: [],
+ endpointCount: 1,
+ endpoints: [{ indigoDeviceId: 999001, endpointNumber: 2, role: "onOffPlugInUnit" }],
+ drift: [],
+} satisfies StatusReport;
+
+/** §3.7 state 1: never commissioned — the basic window with the persisted codes. */
+export const pairingUncommissioned = {
+ commissioned: false,
+ windowOpen: true,
+ windowExpiresAt: null,
+ manualPairingCode: "34970112332",
+ qrPairingCode: "MT:-24J0AFN00KA0648G00",
+ fabrics: [],
+} satisfies PairingReport;
+
+/** §3.7 state 2: commissioned, no window — codes are gone and stay gone. */
+export const pairingCommissioned = {
+ commissioned: true,
+ windowOpen: false,
+ windowExpiresAt: null,
+ manualPairingCode: null,
+ qrPairingCode: null,
+ fabrics: [{ fabricIndex: 1, label: "Apple Home", vendorId: 4937 }],
+} satisfies PairingReport;
+
+/** §3.7 state 3: commissioned *and* an enhanced window open — non-null expiry. */
+export const pairingCommissionedWindowOpen = {
+ commissioned: true,
+ windowOpen: true,
+ windowExpiresAt: "2026-08-04T12:15:00.000Z",
+ manualPairingCode: "34970112332",
+ qrPairingCode: "MT:-24J0AFN00KA0648G00",
+ fabrics: [{ fabricIndex: 1, label: "Apple Home", vendorId: 4937 }],
+} satisfies PairingReport;
+
+export const commissioningWindow = {
+ manualPairingCode: "34970112332",
+ qrPairingCode: "MT:-24J0AFN00KA0648G00",
+ windowExpiresAt: "2026-08-04T12:15:00.000Z",
+} satisfies CommissioningWindowResult;
+
+export const windowClosedExpired = {
+ event: "window_closed",
+ data: { reason: "expired" },
+} satisfies EventFrame;
+
+export const windowClosedCommissioned = {
+ event: "window_closed",
+ data: { reason: "commissioned" },
+} satisfies EventFrame;
+
+export const versionMismatch = {
+ message_id: "m2",
+ error_code: "version_mismatch",
+ details: "Node speaks protocol version 1, client sent 2",
+} satisfies ErrorFrame;
+
+export const notAttached = {
+ message_id: "m3",
+ error_code: "not_attached",
+ details: "get_status requires a successful attach first",
+} satisfies ErrorFrame;
+
+export const unknownCommand = {
+ message_id: "m4",
+ error_code: "unknown_command",
+ details: "Unknown command upsert_endpoint",
+} satisfies ErrorFrame;
diff --git a/bridge-node/test/fixtures.test.ts b/bridge-node/test/fixtures.test.ts
new file mode 100644
index 0000000..c88f995
--- /dev/null
+++ b/bridge-node/test/fixtures.test.ts
@@ -0,0 +1,51 @@
+/**
+ * §7 testing contract: the golden JSON is shared with the Python suite, so it
+ * cannot itself be typed. `fixture-shapes.ts` is its compile-time mirror; this
+ * file is the runtime half that keeps the two honest.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import * as shapes from "./fixture-shapes.js";
+import { golden } from "./stub-bridge.js";
+
+describe("golden fixtures match their typed mirror", () => {
+ const cases: [string, unknown, unknown][] = [
+ ["handshake", golden.handshake, shapes.handshake],
+ ["attach result", golden.attach.response.result, shapes.status],
+ ["get_status result", golden.get_status.response.result, shapes.status],
+ ["get_pairing (uncommissioned)", golden.get_pairing_uncommissioned.response.result, shapes.pairingUncommissioned],
+ ["get_pairing (commissioned)", golden.get_pairing_commissioned.response.result, shapes.pairingCommissioned],
+ [
+ "get_pairing (commissioned, window open)",
+ golden.get_pairing_commissioned_window_open.response.result,
+ shapes.pairingCommissionedWindowOpen,
+ ],
+ ["open_commissioning_window result", golden.open_commissioning_window.response.result, shapes.commissioningWindow],
+ ["window_closed (expired)", golden.window_closed_expired, shapes.windowClosedExpired],
+ ["window_closed (commissioned)", golden.window_closed_commissioned, shapes.windowClosedCommissioned],
+ ["attach version_mismatch", golden.attach_version_mismatch.response, shapes.versionMismatch],
+ ["not_attached", golden.not_attached.response, shapes.notAttached],
+ ["unknown_command", golden.unknown_command.response, shapes.unknownCommand],
+ ];
+
+ for (const [name, actual, expected] of cases) {
+ it(`${name} is shape-identical`, () => {
+ assert.deepEqual(actual, expected);
+ });
+ }
+
+ it("covers all four §3.7 pairing states", () => {
+ // uncommissioned+window, commissioned+no window, commissioned+window;
+ // the fourth (uncommissioned, no window) cannot exist — an
+ // uncommissioned node always advertises.
+ const states = [
+ shapes.pairingUncommissioned,
+ shapes.pairingCommissioned,
+ shapes.pairingCommissionedWindowOpen,
+ ].map(state => `${state.commissioned}/${state.windowOpen}`);
+ assert.deepEqual(states.sort(), ["false/true", "true/false", "true/true"]);
+ assert.notEqual(shapes.pairingCommissionedWindowOpen.windowExpiresAt, null);
+ });
+});
diff --git a/bridge-node/test/fixtures/e0-frames.json b/bridge-node/test/fixtures/e0-frames.json
new file mode 100644
index 0000000..3c17255
--- /dev/null
+++ b/bridge-node/test/fixtures/e0-frames.json
@@ -0,0 +1,207 @@
+{
+ "_comment": [
+ "Golden frames for the E0 subset of BRIDGE_PROTOCOL.md (§7 testing contract).",
+ "Shapes only — bridgeVersion/matterJsVersion are substituted at assert time,",
+ "since they track package.json and the pinned matter.js release.",
+ "E1 extends this file with the endpoint CRUD commands (§3.2-§3.5)."
+ ],
+ "handshake": {
+ "protocolVersion": 1,
+ "bridgeVersion": "0.0.0-test",
+ "matterJsVersion": "0.0.0-test"
+ },
+ "attach": {
+ "request": {
+ "message_id": "m1",
+ "command": "attach",
+ "args": {
+ "protocolVersion": 1,
+ "pluginVersion": "2026.8.1",
+ "endpoints": []
+ }
+ },
+ "response": {
+ "message_id": "m1",
+ "result": {
+ "commissioned": false,
+ "fabrics": [],
+ "endpointCount": 1,
+ "endpoints": [
+ {
+ "indigoDeviceId": 999001,
+ "endpointNumber": 2,
+ "role": "onOffPlugInUnit"
+ }
+ ],
+ "drift": []
+ }
+ }
+ },
+ "attach_version_mismatch": {
+ "request": {
+ "message_id": "m2",
+ "command": "attach",
+ "args": {
+ "protocolVersion": 2,
+ "pluginVersion": "2026.8.1",
+ "endpoints": []
+ }
+ },
+ "response": {
+ "message_id": "m2",
+ "error_code": "version_mismatch",
+ "details": "Node speaks protocol version 1, client sent 2"
+ }
+ },
+ "not_attached": {
+ "request": {
+ "message_id": "m3",
+ "command": "get_status",
+ "args": {}
+ },
+ "response": {
+ "message_id": "m3",
+ "error_code": "not_attached",
+ "details": "get_status requires a successful attach first"
+ }
+ },
+ "unknown_command": {
+ "request": {
+ "message_id": "m4",
+ "command": "upsert_endpoint",
+ "args": {
+ "endpoint": {
+ "indigoDeviceId": 123456789,
+ "role": "onOffLight",
+ "label": "Kitchen Lamp",
+ "reachable": true,
+ "states": { "onOff": true },
+ "options": {}
+ }
+ }
+ },
+ "response": {
+ "message_id": "m4",
+ "error_code": "unknown_command",
+ "details": "Unknown command upsert_endpoint"
+ }
+ },
+ "get_status": {
+ "request": {
+ "message_id": "m5",
+ "command": "get_status",
+ "args": {}
+ },
+ "response": {
+ "message_id": "m5",
+ "result": {
+ "commissioned": false,
+ "fabrics": [],
+ "endpointCount": 1,
+ "endpoints": [
+ {
+ "indigoDeviceId": 999001,
+ "endpointNumber": 2,
+ "role": "onOffPlugInUnit"
+ }
+ ],
+ "drift": []
+ }
+ }
+ },
+ "get_pairing_uncommissioned": {
+ "request": {
+ "message_id": "m6",
+ "command": "get_pairing",
+ "args": {}
+ },
+ "response": {
+ "message_id": "m6",
+ "result": {
+ "commissioned": false,
+ "windowOpen": true,
+ "windowExpiresAt": null,
+ "manualPairingCode": "34970112332",
+ "qrPairingCode": "MT:-24J0AFN00KA0648G00",
+ "fabrics": []
+ }
+ }
+ },
+ "get_pairing_commissioned": {
+ "request": {
+ "message_id": "m7",
+ "command": "get_pairing",
+ "args": {}
+ },
+ "response": {
+ "message_id": "m7",
+ "result": {
+ "commissioned": true,
+ "windowOpen": false,
+ "windowExpiresAt": null,
+ "manualPairingCode": null,
+ "qrPairingCode": null,
+ "fabrics": [
+ {
+ "fabricIndex": 1,
+ "label": "Apple Home",
+ "vendorId": 4937
+ }
+ ]
+ }
+ }
+ },
+ "get_pairing_commissioned_window_open": {
+ "request": {
+ "message_id": "m9",
+ "command": "get_pairing",
+ "args": {}
+ },
+ "response": {
+ "message_id": "m9",
+ "result": {
+ "commissioned": true,
+ "windowOpen": true,
+ "windowExpiresAt": "2026-08-04T12:15:00.000Z",
+ "manualPairingCode": "34970112332",
+ "qrPairingCode": "MT:-24J0AFN00KA0648G00",
+ "fabrics": [
+ {
+ "fabricIndex": 1,
+ "label": "Apple Home",
+ "vendorId": 4937
+ }
+ ]
+ }
+ }
+ },
+ "open_commissioning_window": {
+ "request": {
+ "message_id": "m8",
+ "command": "open_commissioning_window",
+ "args": {
+ "durationSeconds": 900
+ }
+ },
+ "response": {
+ "message_id": "m8",
+ "result": {
+ "manualPairingCode": "34970112332",
+ "qrPairingCode": "MT:-24J0AFN00KA0648G00",
+ "windowExpiresAt": "2026-08-04T12:15:00.000Z"
+ }
+ }
+ },
+ "window_closed_expired": {
+ "event": "window_closed",
+ "data": {
+ "reason": "expired"
+ }
+ },
+ "window_closed_commissioned": {
+ "event": "window_closed",
+ "data": {
+ "reason": "commissioned"
+ }
+ }
+}
diff --git a/bridge-node/test/protocol.test.ts b/bridge-node/test/protocol.test.ts
new file mode 100644
index 0000000..dfdd696
--- /dev/null
+++ b/bridge-node/test/protocol.test.ts
@@ -0,0 +1,381 @@
+/**
+ * BRIDGE_PROTOCOL.md conformance for the E0 command subset, run against the
+ * real ws-server with the Matter node stubbed out.
+ */
+
+import assert from "node:assert/strict";
+import { after, before, describe, it } from "node:test";
+
+import { ErrorCode, PROTOCOL_VERSION, ProtocolError } from "../src/protocol.js";
+import { BridgeWsServer } from "../src/ws-server.js";
+import { TestClient } from "./client.js";
+import { golden, StubBridge } from "./stub-bridge.js";
+
+const BRIDGE_VERSION = "0.1.0-test";
+const MATTER_JS_VERSION = "0.17.8";
+
+const bridge = new StubBridge();
+const server = new BridgeWsServer({
+ port: 0,
+ bridge,
+ bridgeVersion: BRIDGE_VERSION,
+ matterJsVersion: MATTER_JS_VERSION,
+ log: () => {},
+});
+
+async function connect(): Promise {
+ const client = await TestClient.connect(server.port);
+ await client.next(); // consume the handshake
+ return client;
+}
+
+async function attach(client: TestClient): Promise> {
+ return client.request(golden.attach.request);
+}
+
+before(async () => {
+ await server.listen();
+});
+
+after(async () => {
+ await server.close();
+});
+
+describe("handshake (§2)", () => {
+ it("sends the bare handshake frame first, with no message_id", async () => {
+ const client = await TestClient.connect(server.port);
+ const frame = await client.next();
+ assert.deepEqual(frame, {
+ protocolVersion: PROTOCOL_VERSION,
+ bridgeVersion: BRIDGE_VERSION,
+ matterJsVersion: MATTER_JS_VERSION,
+ });
+ assert.equal(Object.keys(golden.handshake).sort().join(","), Object.keys(frame).sort().join(","));
+ client.close();
+ });
+});
+
+describe("attach (§3.1)", () => {
+ it("accepts a matching protocol version and returns a StatusReport", async () => {
+ const client = await connect();
+ const response = await attach(client);
+ assert.deepEqual(response, golden.attach.response);
+ client.close();
+ });
+
+ it("rejects a mismatched protocolVersion and closes the socket", async () => {
+ const client = await connect();
+ const response = await client.request(golden.attach_version_mismatch.request);
+ assert.deepEqual(response, golden.attach_version_mismatch.response);
+ await client.waitForClose();
+ });
+
+ it("rejects a non-numeric protocolVersion as malformed_args", async () => {
+ const client = await connect();
+ const response = await client.request({
+ message_id: "bad",
+ command: "attach",
+ args: { protocolVersion: "1" },
+ });
+ assert.equal(response.error_code, "malformed_args");
+ assert.equal(response.message_id, "bad");
+ client.close();
+ });
+
+ it("supersedes the incumbent attached client", async () => {
+ const first = await connect();
+ await attach(first);
+
+ const second = await connect();
+ await attach(second);
+
+ await first.waitForClose();
+
+ // The superseding client is still usable.
+ const status = await second.request(golden.get_status.request);
+ assert.deepEqual(status, golden.get_status.response);
+ second.close();
+ });
+
+ it("re-attaches the same socket without closing it", async () => {
+ // §2 supersession must not fire on the incumbent when it *is* the
+ // socket attaching: a plugin that re-attaches to refresh its endpoint
+ // set would otherwise hang up on itself.
+ const client = await connect();
+ await attach(client);
+ const again = await attach(client);
+ assert.deepEqual(again, golden.attach.response);
+ assert.equal(client.closed, false);
+
+ const status = await client.request(golden.get_status.request);
+ assert.deepEqual(status, golden.get_status.response);
+ assert.equal(client.closed, false);
+ client.close();
+ });
+});
+
+describe("gating (§1.1)", () => {
+ it("refuses non-attach commands before attach with not_attached", async () => {
+ const client = await connect();
+ const response = await client.request(golden.not_attached.request);
+ assert.deepEqual(response, golden.not_attached.response);
+ client.close();
+ });
+
+ it("returns unknown_command for E1 endpoint CRUD", async () => {
+ const client = await connect();
+ await attach(client);
+ const response = await client.request(golden.unknown_command.request);
+ assert.deepEqual(response, golden.unknown_command.response);
+ client.close();
+ });
+
+ it("prefers not_attached over unknown_command before attach", async () => {
+ const client = await connect();
+ const response = await client.request(golden.unknown_command.request);
+ assert.equal(response.error_code, "not_attached");
+ client.close();
+ });
+
+ it("returns malformed_args when command is missing", async () => {
+ const client = await connect();
+ const response = await client.request({ message_id: "nocmd" });
+ assert.equal(response.error_code, "malformed_args");
+ client.close();
+ });
+
+ it("echoes message_id verbatim", async () => {
+ const client = await connect();
+ await attach(client);
+ const response = await client.request({
+ message_id: "an-opaque-🔑-id",
+ command: "get_status",
+ args: {},
+ });
+ assert.equal(response.message_id, "an-opaque-🔑-id");
+ client.close();
+ });
+});
+
+describe("get_pairing (§3.7)", () => {
+ it("reports the initial window as open with the persisted codes", async () => {
+ bridge.commissioned = false;
+ const client = await connect();
+ await attach(client);
+ const response = await client.request(golden.get_pairing_uncommissioned.request);
+ assert.deepEqual(response, golden.get_pairing_uncommissioned.response);
+
+ const result = response.result as Record;
+ assert.equal(result.windowOpen, true);
+ assert.notEqual(result.manualPairingCode, null);
+ assert.notEqual(result.qrPairingCode, null);
+ client.close();
+ });
+
+ it("nulls the codes once commissioned with no window open", async () => {
+ bridge.commissioned = true;
+ const client = await connect();
+ await attach(client);
+ const response = await client.request(golden.get_pairing_commissioned.request);
+ assert.deepEqual(response, golden.get_pairing_commissioned.response);
+
+ const result = response.result as Record;
+ assert.equal(result.windowOpen, false);
+ assert.equal(result.manualPairingCode, null);
+ assert.equal(result.windowExpiresAt, null);
+ bridge.commissioned = false;
+ client.close();
+ });
+
+ it("reports codes and a non-null expiry while commissioned with a window open", async () => {
+ bridge.commissioned = true;
+ bridge.windowOpen = true;
+ const client = await connect();
+ await attach(client);
+ const response = await client.request(golden.get_pairing_commissioned_window_open.request);
+ assert.deepEqual(response, golden.get_pairing_commissioned_window_open.response);
+
+ const result = response.result as Record;
+ assert.equal(result.commissioned, true);
+ assert.equal(result.windowOpen, true);
+ assert.notEqual(result.windowExpiresAt, null);
+ assert.notEqual(result.manualPairingCode, null);
+ bridge.commissioned = false;
+ bridge.windowOpen = false;
+ client.close();
+ });
+});
+
+describe("open_commissioning_window (§3.8)", () => {
+ it("passes durationSeconds through and returns the window result", async () => {
+ const client = await connect();
+ await attach(client);
+ const response = await client.request(golden.open_commissioning_window.request);
+ assert.deepEqual(response, golden.open_commissioning_window.response);
+ assert.equal(bridge.openWindowCalls.at(-1), 900);
+ client.close();
+ });
+
+ it("bounds durationSeconds to Matter's 180-900s window", async () => {
+ const client = await connect();
+ await attach(client);
+ // 0 and 60 are below Matter's MinCommissioningTimeout; 901 is above its
+ // maximum, and matter.js 0.17.8 would never time a longer window out.
+ for (const durationSeconds of [0, -1, 60, 179, 901, 3600, 900.5]) {
+ const response = await client.request({
+ message_id: `dur-${durationSeconds}`,
+ command: "open_commissioning_window",
+ args: { durationSeconds },
+ });
+ assert.equal(response.error_code, "malformed_args", `durationSeconds ${durationSeconds} was accepted`);
+ }
+ for (const durationSeconds of [180, 900]) {
+ const response = await client.request({
+ message_id: `ok-${durationSeconds}`,
+ command: "open_commissioning_window",
+ args: { durationSeconds },
+ });
+ assert.ok("result" in response, `durationSeconds ${durationSeconds} was rejected`);
+ assert.equal(bridge.openWindowCalls.at(-1), durationSeconds);
+ }
+ client.close();
+ });
+
+ it("reports an unexpected facade failure as internal, with the message as details", async () => {
+ // The StubBridge hook exists precisely so this path is covered without
+ // a Matter stack that can be made to fail on demand.
+ bridge.openWindowError = new Error("mDNS advertiser is down");
+ try {
+ const client = await connect();
+ await attach(client);
+ const response = await client.request(golden.open_commissioning_window.request);
+ assert.equal(response.error_code, "internal");
+ assert.equal(response.details, "mDNS advertiser is down");
+ assert.equal(response.message_id, golden.open_commissioning_window.request.message_id);
+ client.close();
+ } finally {
+ bridge.openWindowError = undefined;
+ }
+ });
+
+ it("passes a ProtocolError through with its own error_code", async () => {
+ bridge.openWindowError = new ProtocolError(
+ ErrorCode.commissioningWindowFailed,
+ "A commissioning window is already open",
+ );
+ try {
+ const client = await connect();
+ await attach(client);
+ const response = await client.request(golden.open_commissioning_window.request);
+ assert.equal(response.error_code, "commissioning_window_failed");
+ assert.equal(response.details, "A commissioning window is already open");
+ client.close();
+ } finally {
+ bridge.openWindowError = undefined;
+ }
+ });
+});
+
+describe("frame hygiene (§1)", () => {
+ it("drops every shape of garbage without answering or dying, and stays usable", async () => {
+ const client = await connect();
+ await attach(client);
+
+ const garbage: (string | Buffer)[] = [
+ "{ not json",
+ "",
+ '"hi"',
+ "42",
+ "null",
+ "[1, 2, 3]",
+ JSON.stringify({ command: "get_status", args: {} }), // no message_id
+ JSON.stringify({ message_id: 7, command: "get_status" }), // non-string message_id
+ JSON.stringify({ event: "window_closed", data: { reason: "expired" } }), // events are node→plugin
+ Buffer.from([0x00, 0xff, 0x10, 0x80]), // binary frame
+ ];
+ for (const payload of garbage) {
+ client.sendRaw(payload);
+ }
+
+ // A well-formed follow-up is still answered, and it is the *only* frame
+ // that comes back — nothing above produced a response.
+ const response = await client.request(golden.get_status.request);
+ assert.deepEqual(response, golden.get_status.response);
+ assert.equal(client.buffered, 0);
+ assert.equal(client.closed, false);
+ client.close();
+ });
+});
+
+describe("ordering (§1)", () => {
+ it("answers pipelined frames in receipt order even when the first awaits", async () => {
+ // open_commissioning_window genuinely awaits (crypto); a get_pairing
+ // pipelined behind it resolves synchronously. Without a per-socket
+ // handler chain the second response overtakes the first.
+ bridge.delayOpenWindowMs = 50;
+ try {
+ const client = await connect();
+ await attach(client);
+
+ client.send({
+ message_id: "first-slow",
+ command: "open_commissioning_window",
+ args: { durationSeconds: 900 },
+ });
+ client.send({ message_id: "second-fast", command: "get_pairing", args: {} });
+
+ const first = await client.next();
+ const second = await client.next();
+ assert.equal(first.message_id, "first-slow");
+ assert.equal(second.message_id, "second-fast");
+ client.close();
+ } finally {
+ bridge.delayOpenWindowMs = 0;
+ }
+ });
+});
+
+describe("window_closed event (§3.8/§5)", () => {
+ it("emits the golden frame to the attached client for each reason", async () => {
+ const client = await connect();
+ await attach(client);
+
+ bridge.emitWindowClosed("expired");
+ assert.deepEqual(await client.next(), golden.window_closed_expired);
+
+ bridge.emitWindowClosed("commissioned");
+ assert.deepEqual(await client.next(), golden.window_closed_commissioned);
+
+ // Events carry no message_id (§1) — the plugin must not try to match one.
+ assert.equal("message_id" in golden.window_closed_expired, false);
+ client.close();
+ });
+
+ it("drops the event when nobody is attached", async () => {
+ // Its own server and stub: `#attached` is server-wide state, and the
+ // shared server has attached clients throughout this file.
+ const lonelyBridge = new StubBridge();
+ const lonely = new BridgeWsServer({
+ port: 0,
+ bridge: lonelyBridge,
+ bridgeVersion: BRIDGE_VERSION,
+ matterJsVersion: MATTER_JS_VERSION,
+ log: () => {},
+ });
+ await lonely.listen();
+ try {
+ // Nothing connected at all.
+ lonelyBridge.emitWindowClosed("expired");
+
+ // Connected but never attached: still no addressee.
+ const client = await TestClient.connect(lonely.port);
+ await client.next(); // handshake
+ lonelyBridge.emitWindowClosed("expired");
+ await assert.rejects(client.next(200));
+ assert.equal(client.closed, false);
+ client.close();
+ } finally {
+ await lonely.close();
+ }
+ });
+});
diff --git a/bridge-node/test/storage.test.ts b/bridge-node/test/storage.test.ts
new file mode 100644
index 0000000..f9227ec
--- /dev/null
+++ b/bridge-node/test/storage.test.ts
@@ -0,0 +1,206 @@
+/**
+ * Identity persistence: randomised once, stable thereafter, never a trivial
+ * passcode.
+ */
+
+import assert from "node:assert/strict";
+import { chmodSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, it } from "node:test";
+
+import {
+ DISCRIMINATOR_MAX,
+ generateDiscriminator,
+ generatePasscode,
+ INVALID_PASSCODES,
+ isValidPasscode,
+ loadOrCreateIdentity,
+ nodeUniqueIdFor,
+ PASSCODE_MAX,
+ PASSCODE_MIN,
+ serialNumberFor,
+} from "../src/storage.js";
+
+function scratch(): string {
+ return mkdtempSync(join(tmpdir(), "indigo-bridge-test-"));
+}
+
+describe("passcode generation", () => {
+ it("never produces a trivial passcode", () => {
+ for (let i = 0; i < 2000; i++) {
+ const passcode = generatePasscode();
+ assert.ok(!INVALID_PASSCODES.includes(passcode), `generated trivial passcode ${passcode}`);
+ assert.ok(passcode >= 1 && passcode <= PASSCODE_MAX);
+ }
+ });
+
+ it("rejects the spec's invalid list and out-of-range values", () => {
+ for (const invalid of INVALID_PASSCODES) {
+ assert.equal(isValidPasscode(invalid), false, `${invalid} should be invalid`);
+ }
+ assert.equal(isValidPasscode(0), false);
+ assert.equal(isValidPasscode(-1), false);
+ assert.equal(isValidPasscode(PASSCODE_MAX + 1), false);
+ assert.equal(isValidPasscode(1.5), false);
+ assert.equal(isValidPasscode(20202021), true);
+ // The inclusive bounds are legal values, not off-by-one rejects.
+ assert.equal(isValidPasscode(PASSCODE_MIN), true);
+ assert.equal(isValidPasscode(1), true);
+ assert.equal(isValidPasscode(PASSCODE_MAX), true);
+ });
+
+ it("names 0 on the invalid list, not just outside the bounds", () => {
+ // A persisted `"passcode": 0` must be caught by the spec's trivial list
+ // as well as by the range check.
+ assert.ok(INVALID_PASSCODES.includes(0));
+ });
+
+ it("never uses the matter.js example defaults verbatim by chance-free construction", () => {
+ // 20202021/3840 are legal values; what matters is that we do not *default*
+ // to them. A fresh identity is drawn from the full range each time.
+ const seen = new Set();
+ for (let i = 0; i < 200; i++) {
+ seen.add(generatePasscode());
+ }
+ assert.ok(seen.size > 190, "passcodes are not being randomised");
+ });
+
+ it("produces 12-bit discriminators", () => {
+ for (let i = 0; i < 2000; i++) {
+ const value = generateDiscriminator();
+ assert.ok(Number.isInteger(value) && value >= 0 && value <= DISCRIMINATOR_MAX);
+ }
+ });
+});
+
+describe("loadOrCreateIdentity", () => {
+ it("creates on first run and returns the same identity thereafter", () => {
+ const dir = scratch();
+ try {
+ const first = loadOrCreateIdentity(dir);
+ assert.ok(isValidPasscode(first.passcode));
+ assert.ok(first.discriminator <= DISCRIMINATOR_MAX);
+ assert.ok(first.installId.length > 0);
+
+ const second = loadOrCreateIdentity(dir);
+ assert.deepEqual(second, first);
+
+ const onDisk = JSON.parse(readFileSync(join(dir, "identity.json"), "utf8"));
+ assert.deepEqual(onDisk, first);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("replaces a corrupt or invalid identity file", () => {
+ const dir = scratch();
+ try {
+ writeFileSync(join(dir, "identity.json"), "{ not json");
+ const recovered = loadOrCreateIdentity(dir);
+ assert.ok(isValidPasscode(recovered.passcode));
+
+ writeFileSync(join(dir, "identity.json"), JSON.stringify({ installId: "x", passcode: 11111111, discriminator: 1 }));
+ const replaced = loadOrCreateIdentity(dir);
+ assert.ok(isValidPasscode(replaced.passcode));
+ assert.notEqual(replaced.passcode, 11111111);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("regenerates an identity whose discriminator is out of 12-bit range, and says so", () => {
+ const dir = scratch();
+ try {
+ writeFileSync(
+ join(dir, "identity.json"),
+ JSON.stringify({ installId: "x", passcode: 20202021, discriminator: DISCRIMINATOR_MAX + 1 }),
+ );
+ const logs: string[] = [];
+ const replaced = loadOrCreateIdentity(dir, message => logs.push(message));
+ assert.ok(replaced.discriminator <= DISCRIMINATOR_MAX);
+ assert.notEqual(replaced.installId, "x");
+ assert.equal(logs.length, 1);
+ assert.match(logs[0]!, /Replacing bridge identity/);
+ assert.match(logs[0]!, /not a usable identity/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("distinguishes a missing file from an unreadable one in the log", () => {
+ const dir = scratch();
+ try {
+ const minted: string[] = [];
+ loadOrCreateIdentity(dir, message => minted.push(message));
+ assert.equal(minted.length, 1);
+ assert.match(minted[0]!, /minting new bridge identity/);
+
+ const reloaded: string[] = [];
+ loadOrCreateIdentity(dir, message => reloaded.push(message));
+ assert.match(reloaded[0]!, /Loaded bridge identity/);
+
+ writeFileSync(join(dir, "identity.json"), "{ not json");
+ const corrupt: string[] = [];
+ loadOrCreateIdentity(dir, message => corrupt.push(message));
+ assert.match(corrupt[0]!, /Replacing bridge identity/);
+ // The caught parse error is quoted, so the log says *why*.
+ assert.match(corrupt[0]!, /unreadable \(.+\)/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("writes atomically and leaves no temp file behind", () => {
+ const dir = scratch();
+ try {
+ loadOrCreateIdentity(dir);
+ assert.deepEqual(readdirSync(dir), ["identity.json"]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("cleans up the temp file when the rename cannot happen", t => {
+ if (process.getuid?.() === 0) {
+ t.skip("root ignores directory permissions");
+ return;
+ }
+ const dir = scratch();
+ const nested = join(dir, "bridge");
+ try {
+ // A read-only directory makes writeFileSync fail after mkdirSync
+ // succeeded, which is the path the temp-file cleanup guards.
+ loadOrCreateIdentity(nested);
+ chmodSync(nested, 0o500);
+ writeFileSync(join(nested, "identity.json"), "{ not json");
+ assert.throws(() => loadOrCreateIdentity(nested));
+ chmodSync(nested, 0o700);
+ assert.deepEqual(readdirSync(nested), ["identity.json"]);
+ } finally {
+ try {
+ chmodSync(nested, 0o700);
+ } catch {
+ // Directory may not exist if the first load failed.
+ }
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("derives a Matter-legal serial number and a distinct unique id", () => {
+ const dir = scratch();
+ try {
+ const identity = loadOrCreateIdentity(dir);
+ const serial = serialNumberFor(identity);
+ const unique = nodeUniqueIdFor(identity);
+ for (const value of [serial, unique]) {
+ assert.ok(value.length > 0 && value.length <= 32);
+ assert.ok(!value.includes("-"));
+ }
+ // Matter requires UniqueID and SerialNumber to differ.
+ assert.notEqual(serial, unique);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/bridge-node/test/stub-bridge.ts b/bridge-node/test/stub-bridge.ts
new file mode 100644
index 0000000..44992e3
--- /dev/null
+++ b/bridge-node/test/stub-bridge.ts
@@ -0,0 +1,93 @@
+/**
+ * A {@link BridgeFacade} that returns the golden fixture payloads verbatim.
+ *
+ * The protocol tests exercise the real ws-server against this, so they need no
+ * Matter stack, no network and no commissioned state.
+ */
+
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import type {
+ BridgeFacade,
+ CommissioningWindowResult,
+ PairingReport,
+ StatusReport,
+ WindowClosedReason,
+} from "../src/protocol.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+export interface GoldenExchange {
+ request: Record;
+ response: Record;
+}
+
+export interface GoldenFrames {
+ handshake: Record;
+ attach: GoldenExchange;
+ attach_version_mismatch: GoldenExchange;
+ not_attached: GoldenExchange;
+ unknown_command: GoldenExchange;
+ get_status: GoldenExchange;
+ get_pairing_uncommissioned: GoldenExchange;
+ get_pairing_commissioned: GoldenExchange;
+ get_pairing_commissioned_window_open: GoldenExchange;
+ open_commissioning_window: GoldenExchange;
+ window_closed_expired: Record;
+ window_closed_commissioned: Record;
+}
+
+export const golden: GoldenFrames = JSON.parse(
+ readFileSync(join(here, "fixtures", "e0-frames.json"), "utf8"),
+) as GoldenFrames;
+
+export class StubBridge implements BridgeFacade {
+ commissioned = false;
+ /** Only meaningful while {@link commissioned}: selects the 3rd §3.7 state. */
+ windowOpen = false;
+ openWindowError?: Error;
+ /**
+ * Makes `openCommissioningWindow` genuinely slow, for the ordering test.
+ * A single tick is not enough: `ws` delivers pipelined frames in separate
+ * read events, so a one-tick handler finishes before the next frame even
+ * arrives and the out-of-order bug never shows.
+ */
+ delayOpenWindowMs = 0;
+ readonly openWindowCalls: number[] = [];
+ #windowClosed?: (reason: WindowClosedReason) => void;
+
+ getStatus(): StatusReport {
+ return structuredClone(golden.get_status.response.result) as StatusReport;
+ }
+
+ getPairing(): PairingReport {
+ const source = !this.commissioned
+ ? golden.get_pairing_uncommissioned
+ : this.windowOpen
+ ? golden.get_pairing_commissioned_window_open
+ : golden.get_pairing_commissioned;
+ return structuredClone(source.response.result) as PairingReport;
+ }
+
+ async openCommissioningWindow(durationSeconds: number): Promise {
+ this.openWindowCalls.push(durationSeconds);
+ if (this.delayOpenWindowMs > 0) {
+ await new Promise(resolve => setTimeout(resolve, this.delayOpenWindowMs));
+ }
+ if (this.openWindowError !== undefined) {
+ throw this.openWindowError;
+ }
+ return structuredClone(golden.open_commissioning_window.response.result) as CommissioningWindowResult;
+ }
+
+ onWindowClosed(listener: (reason: WindowClosedReason) => void): void {
+ this.#windowClosed = listener;
+ }
+
+ /** Stand in for the Matter stack closing the window. */
+ emitWindowClosed(reason: WindowClosedReason): void {
+ this.#windowClosed?.(reason);
+ }
+}
diff --git a/bridge-node/test/timeout.test.ts b/bridge-node/test/timeout.test.ts
new file mode 100644
index 0000000..e541073
--- /dev/null
+++ b/bridge-node/test/timeout.test.ts
@@ -0,0 +1,49 @@
+/**
+ * §2: a connection that handshakes but never attaches is closed by the node.
+ * Runs on its own server so the short timeout does not affect other tests.
+ */
+
+import assert from "node:assert/strict";
+import { after, before, it } from "node:test";
+
+import { BridgeWsServer } from "../src/ws-server.js";
+import { TestClient } from "./client.js";
+import { StubBridge } from "./stub-bridge.js";
+
+const server = new BridgeWsServer({
+ port: 0,
+ bridge: new StubBridge(),
+ bridgeVersion: "0.1.0-test",
+ matterJsVersion: "0.17.8",
+ log: () => {},
+ unattachedTimeoutMs: 150,
+});
+
+before(async () => {
+ await server.listen();
+});
+
+after(async () => {
+ await server.close();
+});
+
+it("closes an unattached socket after the timeout", async () => {
+ const client = await TestClient.connect(server.port);
+ await client.next(); // handshake
+ await client.waitForClose(2000);
+ assert.equal(client.closed, true);
+});
+
+it("does not close a socket that attached in time", async () => {
+ const client = await TestClient.connect(server.port);
+ await client.next();
+ const response = await client.request({
+ message_id: "a",
+ command: "attach",
+ args: { protocolVersion: 1, pluginVersion: "test", endpoints: [] },
+ });
+ assert.ok("result" in response);
+ await new Promise(resolve => setTimeout(resolve, 400));
+ assert.equal(client.closed, false);
+ client.close();
+});
diff --git a/bridge-node/test/window.test.ts b/bridge-node/test/window.test.ts
new file mode 100644
index 0000000..a2d9057
--- /dev/null
+++ b/bridge-node/test/window.test.ts
@@ -0,0 +1,194 @@
+/**
+ * The commissioning-window state machine (§3.8). Timer and clock are injected,
+ * so every branch — including expiry — is exercised without waiting.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import { ProtocolError, type WindowClosedReason } from "../src/protocol.js";
+import { CommissioningWindow } from "../src/window.js";
+
+const MANUAL = "34970112332";
+const QR = "MT:-24J0AFN00KA0648G00";
+
+interface Harness {
+ window: CommissioningWindow;
+ /** Fire the scheduled expiry, if one is armed. */
+ fire(): void;
+ scheduled(): number | undefined;
+ cancels: number;
+ logs: string[];
+ closed: WindowClosedReason[];
+ expiries: number;
+}
+
+function harness(options: { onExpire?: () => void } = {}): Harness {
+ let pending: { callback: () => void; ms: number } | undefined;
+ const state = {
+ cancels: 0,
+ logs: [] as string[],
+ closed: [] as WindowClosedReason[],
+ expiries: 0,
+ };
+ const window = new CommissioningWindow({
+ now: () => 1_000_000,
+ log: message => state.logs.push(message),
+ schedule: (callback, ms) => {
+ pending = { callback, ms };
+ return () => {
+ pending = undefined;
+ state.cancels++;
+ };
+ },
+ onExpire: () => {
+ state.expiries++;
+ options.onExpire?.();
+ },
+ });
+ window.onClosed(reason => state.closed.push(reason));
+ return {
+ window,
+ fire: () => pending?.callback(),
+ scheduled: () => pending?.ms,
+ get cancels() {
+ return state.cancels;
+ },
+ logs: state.logs,
+ closed: state.closed,
+ get expiries() {
+ return state.expiries;
+ },
+ };
+}
+
+describe("CommissioningWindow", () => {
+ it("starts closed and reports no window", () => {
+ const h = harness();
+ assert.equal(h.window.isOpen, false);
+ assert.equal(h.window.current, undefined);
+ h.window.assertClosed();
+ });
+
+ it("records the codes and an ISO-representable expiry", () => {
+ const h = harness();
+ const expiresAt = h.window.open(900, MANUAL, QR);
+ assert.equal(h.window.isOpen, true);
+ assert.equal(expiresAt.getTime(), 1_000_000 + 900_000);
+ assert.equal(expiresAt.toISOString(), new Date(1_900_000).toISOString());
+ assert.deepEqual(h.window.current, { expiresAt, manualPairingCode: MANUAL, qrPairingCode: QR });
+ assert.equal(h.scheduled(), 900_000);
+ });
+
+ it("refuses a second window without disturbing the first", () => {
+ const h = harness();
+ const first = h.window.open(900, MANUAL, QR);
+ assert.throws(
+ () => h.window.open(900, "other", "other"),
+ (error: unknown) => {
+ assert.ok(error instanceof ProtocolError);
+ assert.equal(error.code, "commissioning_window_failed");
+ assert.equal(error.message, "A commissioning window is already open");
+ return true;
+ },
+ );
+ assert.throws(() => h.window.assertClosed(), ProtocolError);
+ // The live window is untouched — the whole point of guarding first.
+ assert.equal(h.window.current?.manualPairingCode, MANUAL);
+ assert.equal(h.window.current?.expiresAt, first);
+ assert.equal(h.closed.length, 0);
+ });
+
+ it("closes with reason \"expired\" when the timer fires, and ends Matter commissioning once", () => {
+ const h = harness();
+ h.window.open(900, MANUAL, QR);
+ h.fire();
+ assert.deepEqual(h.closed, ["expired"]);
+ assert.equal(h.expiries, 1);
+ assert.equal(h.window.isOpen, false);
+ assert.equal(h.logs.filter(line => line.includes("expired")).length, 1);
+ });
+
+ it("does not announce a second close when Matter reports the end we caused", () => {
+ // Our expiry calls endCommissioning(), which fires matter.js's own end
+ // callback straight back at us — synchronously, in this stand-in.
+ // Exactly one `window_closed`, exactly one log line.
+ let window!: CommissioningWindow;
+ const h = harness({ onExpire: () => window.noteEnded() });
+ window = h.window;
+
+ window.open(900, MANUAL, QR);
+ h.fire();
+
+ assert.deepEqual(h.closed, ["expired"]);
+ assert.equal(h.logs.filter(line => line.startsWith("Commissioning window")).length, 1);
+ // A later, unrelated end callback is still a no-op.
+ assert.equal(window.noteEnded(), false);
+ assert.deepEqual(h.closed, ["expired"]);
+ });
+
+ it("closes with reason \"commissioned\" and cancels the timer when a commissioner completes", () => {
+ const h = harness();
+ h.window.open(900, MANUAL, QR);
+ assert.equal(h.window.noteEnded(), true);
+ assert.deepEqual(h.closed, ["commissioned"]);
+ assert.equal(h.cancels, 1);
+ assert.equal(h.window.isOpen, false);
+ // The cancelled timer cannot fire a late "expired" afterwards.
+ h.fire();
+ assert.deepEqual(h.closed, ["commissioned"]);
+ assert.equal(h.expiries, 0);
+ });
+
+ it("noteEnded on a closed window is a no-op", () => {
+ const h = harness();
+ assert.equal(h.window.noteEnded(), false);
+ assert.deepEqual(h.closed, []);
+ });
+
+ it("clear() drops the window silently, so shutdown emits nothing", () => {
+ const h = harness();
+ h.window.open(900, MANUAL, QR);
+ h.window.clear();
+ assert.equal(h.window.isOpen, false);
+ assert.equal(h.cancels, 1);
+ assert.deepEqual(h.closed, []);
+ h.window.clear(); // idempotent
+ assert.equal(h.cancels, 1);
+ h.window.assertClosed();
+ });
+
+ it("reopens after a close", () => {
+ const h = harness();
+ h.window.open(900, MANUAL, QR);
+ h.window.noteEnded();
+ const second = h.window.open(180, "b", "c");
+ assert.equal(h.scheduled(), 180_000);
+ assert.equal(second.getTime(), 1_000_000 + 180_000);
+ });
+
+ it("catches a throwing window_closed listener and stays consistent", () => {
+ const h = harness();
+ h.window.onClosed(() => {
+ throw new Error("listener exploded");
+ });
+ h.window.open(900, MANUAL, QR);
+ assert.doesNotThrow(() => h.fire());
+ assert.equal(h.window.isOpen, false);
+ assert.ok(h.logs.some(line => line.includes("listener exploded")));
+ // The Matter-side teardown still ran despite the listener failure.
+ assert.equal(h.expiries, 1);
+ });
+
+ it("catches a throwing onExpire so the timer never rethrows", () => {
+ const h = harness({
+ onExpire: () => {
+ throw new Error("env.get raced close()");
+ },
+ });
+ h.window.open(900, MANUAL, QR);
+ assert.doesNotThrow(() => h.fire());
+ assert.deepEqual(h.closed, ["expired"]);
+ assert.ok(h.logs.some(line => line.includes("env.get raced close()")));
+ });
+});
diff --git a/bridge-node/tsconfig.json b/bridge-node/tsconfig.json
new file mode 100644
index 0000000..10aba14
--- /dev/null
+++ b/bridge-node/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "rootDir": "src",
+ "outDir": "dist",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "sourceMap": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/bridge-node/tsconfig.test.json b/bridge-node/tsconfig.test.json
new file mode 100644
index 0000000..e64f9ed
--- /dev/null
+++ b/bridge-node/tsconfig.test.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "outDir": ".test-build",
+ "declaration": false
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"]
+}
diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist
index 07554bb..bd16276 100644
--- a/indigo-matter.indigoPlugin/Contents/Info.plist
+++ b/indigo-matter.indigoPlugin/Contents/Info.plist
@@ -20,7 +20,7 @@
IwsApiVersion
1.0.0
PluginVersion
- 2026.7.22
+ 2026.7.23
ServerApiVersion
3.6