@tether-app/module-sdk
v0.1.1
Published
Official SDK for building Tether third-party modules. Provides the browser-side postMessage bridge client and the Node-side webhook signature verifier.
Maintainers
Readme
@tether-app/module-sdk
Official SDK for building Tether third-party modules. Provides the two most error-prone pieces of the platform contract as thin, well-tested helpers so module authors don't reimplement them (and subtly get them wrong) from scratch.
- Browser:
connectToTether()— wraps the iframe postMessage bridge. Handshake, state hydration, event subscriptions, outbound actions, lifecycle cleanup. - Node:
verifyTetherWebhook()— wraps HMAC-SHA256 signature verification and the 5-minute replay window check required by/platform/v1webhook deliveries. Uses a constant-time comparison internally so callers can't introduce timing oracles.
This SDK is the canonical client for the wire contract documented
in docs/platform-api.md. If anything
in this README disagrees with that document, the platform-api doc
is authoritative — file a bug.
Installation
npm install @tether-app/module-sdkThe package ships two conditional exports so you only pull in the side you need:
import { connectToTether } from '@tether-app/module-sdk/browser'
import { verifyTetherWebhook } from '@tether-app/module-sdk/node'Node 18+ is required (for global crypto and fetch). Types are
included — hand-written, not generated, so the surface is precise.
Browser: connectToTether
Call from inside the iframe Tether hosts for your module. The SDK
handles the tether:ready handshake, waits for the initial
tether:session-state response, and returns a handle with live
state getters and event subscriptions.
import { connectToTether } from '@tether-app/module-sdk/browser'
const tether = await connectToTether()
console.log('session:', tether.sessionId, 'lobby:', tether.lobbyId)
console.log('host:', tether.isSessionHost)
console.log('initial participants:', tether.participants)
// Subscribe to updates. Every on*() returns an unsubscribe fn.
const offState = tether.onStateChange((state) => {
render(state)
})
tether.onParticipantsChange((participants) => {
updatePresenceUi(participants)
})
tether.onModuleEvent((eventName, payload) => {
// Events broadcast by OTHER session viewers via Tether's relay
// or via your backend's POST /platform/v1/sessions/:id/broadcast
if (eventName === 'dice:rolled') showRoll(payload)
})
tether.onSessionEnded(() => {
// The host ended the session. Tear down local state.
teardown()
})
// Outbound actions
tether.emit('dice:rolled', { d20: 17 }) // tether:emit-event
tether.requestParticipants() // tether:request-participants
tether.leave() // tether:leave — navigates user back to the lobbyOptions
connectToTether({
timeoutMs: 10_000, // reject if no session-state arrives
targetWindow: window, // override for tests (no-ops in prod)
parentWindow: window.parent, // override for tests (no-ops in prod)
})Rejection reasons
The returned promise rejects if:
- The current window is not inside an iframe
(
parentWindow === targetWindoworwindow.parentunavailable) - No initial
tether:session-stateresponse arrives withintimeoutMs(default 10s)
Security model
- The SDK validates that
event.sourceof every inbound message matches the parent window — a sibling iframe cannot impersonate the host. - It intentionally does NOT validate
event.origin. Tether's host iframe is sandboxed withoutallow-same-origin, so the iframe runs in an opaque origin and cannot reliably know the parent's real origin. Theevent.sourceidentity check is the strong guarantee here. See the header comment inbrowser/index.jsfor full rationale. - Unknown inbound message types are silently dropped.
- Unknown keys on known messages are passed through (forward-compat
for future protocol additions like A/V
callState).
Node: verifyTetherWebhook
Call from your webhook receiver to verify signed deliveries from
Tether's outbound webhook dispatcher. The function is synchronous
— all inputs are already in memory. It never throws for
protocol-level failures; bad signatures, stale timestamps, and
missing headers are reflected in the valid: false result with a
stable reason code.
Plain Node http server
import http from 'node:http'
import { verifyTetherWebhook, readRawBody } from '@tether-app/module-sdk/node'
const SECRET = process.env.TETHER_WEBHOOK_SECRET
http.createServer(async (req, res) => {
const rawBody = await readRawBody(req)
const result = verifyTetherWebhook({
headers: req.headers,
rawBody,
secret: SECRET,
})
if (!result.valid) {
// Return 401 for any unverifiable request. Do NOT leak the
// reason — Tether treats 4xx as "don't retry", which is what
// you want for any bad-signature case.
res.writeHead(401); res.end(); return
}
console.log('event:', result.event, 'payload:', result.payload)
// result.moduleId and result.timestamp are also available.
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"ok":true}')
}).listen(4100)Express with body-parser
Express strips the raw body by default. Configure express.raw()
for the webhook route so the SDK can verify the signed bytes
exactly as received:
import express from 'express'
import { verifyTetherWebhook } from '@tether-app/module-sdk/node'
const app = express()
const SECRET = process.env.TETHER_WEBHOOK_SECRET
app.post(
'/tether/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const result = verifyTetherWebhook({
headers: req.headers,
rawBody: req.body, // Buffer, per express.raw()
secret: SECRET,
})
if (!result.valid) return res.sendStatus(401)
// req.body is still the raw Buffer here. The parsed event and
// payload come from result, which verified them against the
// signature before parsing.
handleEvent(result.event, result.payload)
res.json({ ok: true })
}
)Do not re-serialize the parsed body and pass it back in as
rawBody. JSON whitespace and key order are part of the signed
bytes; re-stringifying almost always invalidates the signature.
AWS Lambda / API Gateway
import { verifyTetherWebhook } from '@tether-app/module-sdk/node'
export const handler = async (event) => {
const result = verifyTetherWebhook({
headers: event.headers,
rawBody: event.isBase64Encoded
? Buffer.from(event.body, 'base64').toString('utf8')
: event.body,
secret: process.env.TETHER_WEBHOOK_SECRET,
})
if (!result.valid) return { statusCode: 401, body: '' }
await handleEvent(result.event, result.payload)
return { statusCode: 200, body: '{"ok":true}' }
}Result shape
Success:
{
valid: true,
event: 'session:created',
payload: { moduleSessionId: '...', lobbyId: '...', participants: [...] },
moduleId: 'com.example.module',
timestamp: 1700000000,
}Failure:
{ valid: false, reason: 'signature_mismatch' }Failure reasons (stable machine codes):
| reason | meaning |
| ------------------------- | ------- |
| missing_secret | secret arg was empty or not a string |
| missing_signature | X-Tether-Signature header absent |
| missing_timestamp | X-Tether-Timestamp header absent |
| invalid_timestamp | Header value is not a pure decimal integer |
| replay_window_exceeded | Timestamp is more than 300 seconds from receiver's clock |
| signature_mismatch | HMAC-SHA256 digest did not match (body tampered, wrong secret, or malformed sig) |
| invalid_body | rawBody was not a string or Buffer, was not valid JSON, was not an object, or lacked an event name |
Idempotency
Tether's delivery pipeline retries on 5xx and network errors up to
WEBHOOK_MAX_ATTEMPTS (default 3). Your receiver MUST be
idempotent — a 2xx response followed by a lost network ack will
result in the same event being redelivered. Dedupe on
(moduleId, moduleSessionId, event, timestamp) or an equivalent
tuple if strict once-delivery semantics matter for your module.
Running the SDK's own tests
npm run test:sdkRuns the fourth vitest surface (node env, no jsdom) from the repo root. Currently covers both helpers with hand-rolled fakes rather than real DOM / real HTTP — the suite is fast (single-digit ms per case) and independent of the browser / jsdom toolchain.
See packages/module-sdk/test/ for the vectors. The wire-format
lock test in test/node.test.js is the single most important
case: if it ever fails, either the SDK or the backend's
signBody() drifted and any in-flight third-party receiver will
start 401'ing on the next deployment.
Versioning and stability
v0.1 — the API has been dogfooded end-to-end against the Tether testbed module (both the iframe bridge and the webhook receiver) and survived without requiring changes. The surface is stable for third-party consumption.
Past v1.0 the API surface will be frozen and changes will follow semver:
- Bug fixes → patch bump.
- New optional fields on the result / handle → minor bump.
- Any change to required fields, failure reasons, or wire strings → major bump, with the Tether platform continuing to support the previous major for one deprecation cycle.
