Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 90 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
# MiakAPI

MiakAPI is the typed Node.js SDK for running a trusted Miakapp coordinator. A
coordinator owns complete state, access, event, and function declarations for one
integration and exchanges canonical MessagePack frames with the Miakapp relay.
MiakAPI is the typed SDK for running a trusted Node.js coordinator and connecting
a first-party browser application to a Miakapp home. A coordinator owns complete
state, access, event, and function declarations for one integration. The isolated
browser entry point exposes the authenticated user role without bundling Node.js
or coordinator-only dependencies.

Version 4 is a complete replacement for the legacy callback-based MiakAPI 3
client. It is currently an alpha while the Miakapp 3.5 relay is being deployed.

## Requirements
## Coordinator requirements

- Node.js 22.9 or newer
- A Miakapp Home Key or another approved short-lived access-token provider
- A Miakapp relay implementing wire protocol 1.0

MiakAPI is server-side software. Do not ship coordinator credentials, Home Keys,
or access-token providers to a browser or an untrusted plugin runtime.
The default `miakapi` entry point is server-side software. Do not ship
coordinator credentials, Home Keys, or coordinator access-token providers to a
browser or an untrusted plugin runtime.

## Installation

Expand Down Expand Up @@ -147,6 +150,84 @@ const result = await call.result;
MiakAPI never retries state mutations, events, or calls. An idempotency key is
passed to the callee but does not enable hidden retries.

## Trusted browser client

Use the isolated `miakapi/browser` entry point in the first-party Miakapp web
application. It relies on the browser's native WebSocket implementation and does
not expose coordinator declarations, Home Keys, or the Node.js `ws` transport.

```ts
import { createBrowserClient } from 'miakapi/browser';

const client = createBrowserClient({
homeId: 'my-home',
relayUrl: 'wss://relay.example.com/miakapp/ws',
idTokenProvider: {
async getIdToken({ signal }) {
if (signal.aborted) throw signal.reason;
const user = firebaseAuth.currentUser;
if (user === null) throw new Error('The user is signed out');
return user.getIdToken();
},
},
});

await client.start();

const removeStateListener = client.state.subscribe((snapshot) => {
if (!snapshot.stale) renderHome(snapshot.values);
});

const removeHomeListener = client.home.subscribe((home) => {
renderAvailability(home.enrolled, home.coordinators, home.stale);
});

const call = client.calls.start({
function: 'lighting.scene.activate',
arguments: { scene: 'evening' },
timeoutMs: 10_000,
idempotencyKey: 'intent-018f',
});
await call.accepted;
const result = await call.result;

removeStateListener();
removeHomeListener();
await client.stop();
```

`idTokenProvider` is invoked for the initial connection, same-socket
reauthentication, and reconnects. Return a fresh Firebase ID token from trusted
in-memory application state. Never place the token in the relay URL, a WebSocket
subprotocol, persistent browser storage, logs, or error messages. MiakAPI sends
it only inside the authenticated binary protocol handshake or `REAUTH` frame.
Stop and discard the client immediately when the Firebase user signs out or the
selected home or relay changes; create a new client for the new identity tuple.

The configured relay receives that Firebase ID token as a bearer credential and
can observe the home data flowing through it. Until the control plane issues a
short-lived credential scoped to one relay, home, and user role, use this client
only with an official relay or one the user explicitly trusts as completely as
the Miakapp backend. An arbitrary community relay catalogue is not a safe
production use of this authentication profile. The first browser integration
fixture uses synthetic credentials only; Miakapp application wiring remains
blocked on this trust decision.

Browser state snapshots are defensive copies and become `stale` immediately
when continuity is lost. Revision or dictionary mismatches trigger one
fail-closed resynchronization request. Browser calls target the home's default
coordinator by function name, have no progress stream, and are never replayed by
the SDK. Incoming calls are rejected with an application error because this
first user profile deliberately exposes no browser call handlers. An
`outcome_unknown` failure means an effect may already have happened.

Token acquisition, protocol welcome, bootstrap, and reauthentication each have
bounded deadlines. The browser transport also caps individual frames, its
outbound queue, and rolling inbound bytes and frame counts. A native WebSocket
still materializes a complete message before JavaScript can reject it, so these
limits are defense in depth rather than isolation from a malicious relay; a
Worker boundary remains an option for a later hardened browser profile.

## Failure outcomes

Every `CoordinatorFailure` includes an `outcome`:
Expand Down Expand Up @@ -203,8 +284,9 @@ bun install --frozen-lockfile
bun run check
```

The check includes strict type checking, unit and adversarial tests, a Node.js
package smoke test, canonical external conformance, and an npm package dry run.
The check includes strict type checking, unit and adversarial tests, Node.js and
browser-bundle smoke tests, canonical external conformance, and an npm package
dry run.

## License

Expand Down
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "miakapi",
"version": "4.0.0-alpha.0",
"description": "Typed coordinator SDK for Miakapp",
"description": "Typed coordinator and trusted-browser SDK for Miakapp",
"type": "module",
"packageManager": "bun@1.2.23",
"engines": {
Expand All @@ -11,6 +11,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./browser": {
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
}
},
"files": [
Expand All @@ -25,11 +29,12 @@
"build:contract": "tsc -p tsconfig.contract.json",
"pack:check": "npm pack --dry-run",
"prepublishOnly": "bun run check",
"smoke:browser": "node scripts/check-browser-bundle.mjs",
"smoke:node": "node test/node-smoke.mjs",
"test": "bun test",
"test:contract": "bun run build:contract && node scripts/check-contract.mjs",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck && bun run test && bun run build && bun run smoke:node && bun run test:contract && bun run pack:check"
"check": "bun run typecheck && bun run test && bun run build && bun run smoke:node && bun run smoke:browser && bun run test:contract && bun run pack:check"
},
"repository": {
"type": "git",
Expand All @@ -44,6 +49,7 @@
"MiakAPI",
"smart-home",
"coordinator",
"browser",
"websocket"
],
"author": "Mathieu Colmon",
Expand All @@ -52,6 +58,7 @@
"@types/bun": "1.2.23",
"@types/node": "22.20.1",
"@types/ws": "8.18.1",
"playwright": "1.62.1",
"typescript": "7.0.2"
},
"dependencies": {
Expand Down
35 changes: 35 additions & 0 deletions scripts/check-browser-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { spawnSync } from 'node:child_process';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';

const directory = await mkdtemp(path.join(tmpdir(), 'miakapi-browser-smoke-'));
const output = path.join(directory, 'browser.js');

try {
const build = spawnSync(
'bun',
['build', 'src/browser.ts', '--target=browser', '--outfile', output],
{ cwd: process.cwd(), encoding: 'utf8' },
);
if (build.status !== 0) {
throw new Error(`Browser bundle failed:\n${build.stderr || build.stdout}`);
}
const source = await readFile(output, 'utf8');
const forbidden = [
/from\s+["']node:/u,
/require\(["']node:/u,
/from\s+["']ws["']/u,
/require\(["']ws["']\)/u,
];
if (forbidden.some((pattern) => pattern.test(source))) {
throw new Error('Browser bundle contains a Node-only import');
}
process.stdout.write(`${JSON.stringify({
schema: 'miakapi.browser-bundle-smoke/1',
bytes: Buffer.byteLength(source),
node_imports: false,
})}\n`);
} finally {
await rm(directory, { recursive: true, force: true });
}
147 changes: 147 additions & 0 deletions src/browser-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type {
DispatchOutcome,
ProtocolValue,
StartOptions,
StopOptions,
Unsubscribe,
} from './api.js';

export type BrowserClientStatus =
| 'idle'
| 'connecting'
| 'authenticating'
| 'synchronizing'
| 'ready'
| 'reconnecting'
| 'draining'
| 'stopping'
| 'stopped';

export type FirebaseIdTokenReason = 'initial' | 'reauth' | 'reconnect';

export interface FirebaseIdTokenRequest {
readonly homeId: string;
readonly reason: FirebaseIdTokenReason;
readonly signal: AbortSignal;
}

export interface FirebaseIdTokenProvider {
getIdToken(request: FirebaseIdTokenRequest): Promise<string>;
}

export interface BrowserClientLogRecord {
readonly level: 'debug' | 'info' | 'warn' | 'error';
readonly event: string;
readonly status?: BrowserClientStatus;
readonly code?: number;
}

export interface BrowserClientLogger {
write(record: BrowserClientLogRecord): void;
}

export interface BrowserClientOptions {
readonly homeId: string;
readonly relayUrl: string;
readonly idTokenProvider: FirebaseIdTokenProvider;
readonly logger?: BrowserClientLogger;
}

export interface BrowserCoordinatorStatus {
readonly name: string;
readonly generation: number;
readonly status: 'connected' | 'grace';
}

export interface BrowserReadySession {
readonly sessionId: number;
readonly connectedAtMs: number;
readonly enrolled: boolean;
readonly coordinators: readonly BrowserCoordinatorStatus[];
}

export interface BrowserHomeStatus {
readonly enrolled: boolean;
readonly coordinators: readonly BrowserCoordinatorStatus[];
readonly stale: boolean;
}

export interface BrowserHome {
snapshot(): BrowserHomeStatus | undefined;
subscribe(listener: (status: BrowserHomeStatus) => void): Unsubscribe;
}

export interface BrowserClientFailure extends Error {
readonly kind:
| 'protocol'
| 'authentication'
| 'authorization'
| 'conflict'
| 'invalid_lifecycle'
| 'unavailable'
| 'cancelled'
| 'internal';
readonly code?: number;
readonly retryable: boolean;
readonly outcome: DispatchOutcome;
readonly correlation?: {
readonly kind: 'call';
readonly localId: string;
};
}

export interface BrowserLifecycleEvent {
readonly previous: BrowserClientStatus;
readonly current: BrowserClientStatus;
readonly session?: BrowserReadySession;
readonly reason?: BrowserClientFailure;
}

export interface BrowserStateSnapshot {
readonly epoch: Uint8Array;
readonly revision: number;
readonly values: Readonly<Record<string, ProtocolValue>>;
readonly stale: boolean;
}

export interface BrowserState {
snapshot(): BrowserStateSnapshot | undefined;
subscribe(listener: (snapshot: BrowserStateSnapshot) => void): Unsubscribe;
}

export interface BrowserCallOptions {
readonly function: string;
readonly arguments: ProtocolValue;
readonly timeoutMs: number;
readonly idempotencyKey?: string;
readonly signal?: AbortSignal;
}

export interface BrowserCallHandle {
readonly localId: string;
readonly accepted: Promise<void>;
readonly result: Promise<ProtocolValue>;
cancel(): void;
}

export interface BrowserCalls {
start(options: BrowserCallOptions): BrowserCallHandle;
}

export interface BrowserClientErrors {
subscribe(listener: (failure: BrowserClientFailure) => void): Unsubscribe;
}

export interface BrowserClient {
readonly status: BrowserClientStatus;
readonly home: BrowserHome;
readonly state: BrowserState;
readonly calls: BrowserCalls;
readonly errors: BrowserClientErrors;

start(options?: StartOptions): Promise<BrowserReadySession>;
stop(options?: StopOptions): Promise<void>;
subscribe(listener: (event: BrowserLifecycleEvent) => void): Unsubscribe;
}

export type BrowserClientFactory = (options: BrowserClientOptions) => BrowserClient;
Loading
Loading