diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dae078e..6760e8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Added +- **A browser links to a phone or a board over Bluetooth LE.** In + Chromium, `BluetoothCarrier` makes the page a GATT central: its person + picks a device running the SDK in the browser's chooser, the engine + verifies the identity the device serves, the page writes its hello, and + the link carries sessions and messages through the same `BleTransport` + and fragment codec as every binding. A browser is never discoverable over + Bluetooth, and Firefox and Safari have no Web Bluetooth + ([W14](docs/bridges/web.md#w14-bluetooth-is-a-central-the-person-points-at-a-device)). + Not run yet from a real browser to a real device. + - **Browsers on one network find each other.** `NearbyRoom` meets the browsers behind one public address in a Nostr room derived from that address and the application id, lists them by the label each application diff --git a/bindings/web/src/bluetooth.js b/bindings/web/src/bluetooth.js new file mode 100644 index 00000000..89006eca --- /dev/null +++ b/bindings/web/src/bluetooth.js @@ -0,0 +1,335 @@ +// A Bluetooth LE carrier over Web Bluetooth: the page as a GATT central (W14). +// +// Invariants (docs/bridges/web.md): +// - The page is a central and nothing else. It cannot advertise, so nothing +// discovers it, and it serves no GATT, so a peripheral names it only by the +// hello it writes. Every link starts from a person picking a device in the +// browser's chooser, inside a user gesture, or from a device this origin +// was granted before. +// - This file moves bytes. It reads the peripheral's Device id and Identity +// and hands both to the engine, which verifies the identity, derives the +// address and compares it with the Device id exactly; the peer is announced +// under what the engine returns, never under what the peripheral served. +// Fragments are cut and reassembled in Rust, by the codec the conformance +// vectors check. +// - The order is the GATT contract's (docs/spec/ble-framing.md): choose the +// instance, read and verify, subscribe to Message, write Hello, then tell +// the engine the link is up. Nothing is written to Message before the +// hello, so the peripheral can attribute every fragment. +// - The hello is required here, though the chapter makes it optional. The +// chapter's leniency rests on the peripheral dialling an unnamed central +// back, and nothing can dial a browser: a device without Hello, or one +// that refuses the write, would be announced as a neighbour that neither +// receives nor sends. Such a device is refused. +// - A disconnect is watched from the moment the GATT connects. A device that +// drops during the handshake is refused, never announced; one that drops +// later is reported lost exactly once, also when the page closed a link +// whose server was already gone and no event follows. +// - Writes to one link go one at a time, in order. A link whose write fails +// is closed: a fragment lost mid-message leaves the peer reassembling +// something that never completes. +// - One carrier per node. The node has one outbound Bluetooth sink, and a +// second carrier is refused rather than left to take the first one's +// fragments. + +const SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; +const MESSAGE = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; +const DEVICE_ID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; +const IDENTITY = "6e400004-b5a3-f393-e0a9-e50e24dcca9e"; +const APP_TAG = "6e400005-b5a3-f393-e0a9-e50e24dcca9e"; +const HELLO = "6e400006-b5a3-f393-e0a9-e50e24dcca9e"; + +// The write payload assumed when the browser does not report one: the +// fragment floor every binding falls back to (BLE_MAX_FRAGMENT_SIZE). +const FLOOR_PAYLOAD = 185; + +const bytesOf = (view) => new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + +async function characteristics(service) { + try { + return await service.getCharacteristics(); + } catch { + return []; + } +} + +/// Links this page's node to phones and boards running the SDK as a +/// Bluetooth LE peripheral, over Web Bluetooth. Chromium only. Start the node +/// with `ble: true`. +/// +/// A `connection` event carries `{ peer, state }` as each link comes up +/// ("connected") and goes ("closed"). +export class BluetoothCarrier extends EventTarget { + #node; + #bluetooth; + // peer -> link: { device, peer, message, queue, inbound, linked, announced, gone, onDisconnect, onNotify } + #links = new Map(); + #tag = null; + #released = false; + // device -> the handshake in flight for it, which a second connect joins. + #connecting = new Map(); + + constructor(node, { bluetooth = globalThis.navigator?.bluetooth } = {}) { + super(); + if (!bluetooth) throw new Error("this browser has no Web Bluetooth"); + this.#node = node; + this.#bluetooth = bluetooth; + // Throws when another carrier holds the node's sink. + node.setBluetoothSink({ + write: (peer, bytes) => this.#write(peer, bytes), + close: () => this.#nodeStopped(), + }); + } + + /// Whether this browser exposes Web Bluetooth at all. + static get supported() { + return Boolean(globalThis.navigator?.bluetooth); + } + + /// The addresses linked now. + get peers() { + return [...this.#links.keys()]; + } + + /// Opens the browser's chooser for devices offering the SDK's service, and + /// links the one the person picks. Must run inside a user gesture (a + /// click), as the browser requires. Resolves to the peer's address. + async request() { + this.#usable(); + const device = await this.#bluetooth.requestDevice({ filters: [{ services: [SERVICE] }] }); + return this.connect(device); + } + + /// Devices this origin was granted before, where the browser remembers + /// them, to `connect` without the chooser. A remembered device that is out + /// of range may leave `connect` waiting on the browser's own timeout. + async known() { + return (await this.#bluetooth.getDevices?.()) ?? []; + } + + /// Links `device`. Resolves to the address its identity proves; rejects, + /// with the connection closed, when it does not verify, serves no Hello, + /// refuses the hello, or disconnects before the link is up. + async connect(device) { + this.#usable(); + // Already linked on a live connection: the browser hands back the same + // device, and the same characteristics, for one physical device, so a + // second handshake would subscribe a second time and every fragment + // would reach the engine once per handshake. + const held = [...this.#links.values()].find((l) => l.device === device); + if (held && device.gatt.connected) return held.peer; + // A handshake already under way for this device is joined, not raced: + // the engine holds one verification per address, so a second would + // fail its link-up and take the first one's link down with it. + const inflight = this.#connecting.get(device); + if (inflight) return inflight; + const handshake = this.#handshake(device).finally(() => this.#connecting.delete(device)); + this.#connecting.set(device, handshake); + return handshake; + } + + async #handshake(device) { + const server = await device.gatt.connect(); + let ready; + const link = { + device, + peer: null, + message: null, + queue: Promise.resolve(), + // Notifications that arrive before the engine has the link wait for it. + inbound: new Promise((resolve) => (ready = resolve)), + // In #links, so the engine may have it. + linked: false, + // The page was told it is connected. + announced: false, + gone: false, + onDisconnect: null, + onNotify: null, + }; + // Watched before the first await on the server, so a drop anywhere in + // the handshake is seen. + link.onDisconnect = () => { + link.gone = true; + if (link.linked) this.#lost(link); + else this.#detach(link); + }; + device.addEventListener("gattserverdisconnected", link.onDisconnect); + const goneCheck = () => { + if (link.gone || !device.gatt.connected) throw new Error("the device disconnected during the handshake"); + }; + try { + const service = await this.#choose(await server.getPrimaryServices(SERVICE)); + const chars = new Map((await characteristics(service)).map((c) => [c.uuid.toLowerCase(), c])); + const deviceId = chars.get(DEVICE_ID); + const identity = chars.get(IDENTITY); + const message = chars.get(MESSAGE); + const hello = chars.get(HELLO); + if (!deviceId || !identity || !message) { + throw new Error("the device does not serve the SDK's handshake characteristics"); + } + if (!hello) { + // Nothing can dial a browser back, so the hello is the only way the + // peripheral can attribute a write (W14). + throw new Error("the device serves no Hello, and a browser can be named no other way"); + } + const peer = await this.#node.bleVerify( + bytesOf(await deviceId.readValue()), + bytesOf(await identity.readValue()), + ); + link.peer = peer; + link.message = message; + link.onNotify = ({ target }) => { + const bytes = bytesOf(target.value); + link.inbound = link.inbound + .then(() => this.#node.bleFragmentReceived(peer, bytes)) + .catch(() => {}); + }; + message.addEventListener("characteristicvaluechanged", link.onNotify); + await message.startNotifications(); + try { + await hello.writeValueWithResponse(await this.#node.bleHello()); + } catch (error) { + goneCheck(); + throw new Error(`the device refused the hello: ${error?.message ?? error}`); + } + goneCheck(); + // An earlier link to the same address is replaced. The engine keeps + // the peer across the swap, so it is not reported lost. + const previous = this.#links.get(peer); + this.#links.set(peer, link); + link.linked = true; + if (previous) this.#retire(previous); + // Proposed for the characteristic (web-bluetooth #672) and shipped by + // no browser yet, so the floor is what runs today. + const payload = message.maxWriteWithoutResponseSize ?? FLOOR_PAYLOAD; + // In #links before the engine hears of it: the fragments its link-up + // queues reach the sink ahead of this call's reply. + await this.#node.bleLinkUp(peer, payload); + goneCheck(); + // A connect to another device proving the same address replaced this + // link while the engine took it; the newer one is the one announced. + if (this.#links.get(peer) !== link) return peer; + ready(); + link.announced = true; + this.dispatchEvent(new CustomEvent("connection", { detail: { peer, state: "connected" } })); + return peer; + } catch (error) { + if (link.linked) this.#lost(link); + else this.#detach(link); + if (device.gatt.connected) device.gatt.disconnect(); + throw error; + } + } + + /// Closes the link to `peer`, and reports it lost at once, whether or not + /// the browser still has a connection to close. + close(peer) { + const link = this.#links.get(peer); + if (link) this.#drop(link); + } + + /// Closes every link. + closeAll() { + for (const link of [...this.#links.values()]) this.#drop(link); + } + + /// Closes every link and gives the node's Bluetooth sink back, so another + /// carrier can take it. This carrier links nothing afterwards. + release() { + if (this.#released) return; + this.closeAll(); + this.#released = true; + this.#node.setBluetoothSink(null); + } + + // The chapter's instance choice: with one instance, that one; with several, + // among those serving both Device id and Identity, the first whose App tag + // is this application's, else the only untagged one, else the first. + async #choose(services) { + if (services.length === 0) throw new Error("the device does not offer the SDK's service"); + if (services.length === 1) return services[0]; + this.#tag ??= await this.#node.bleAppTag(); + const candidates = []; + for (const service of services) { + const chars = new Map((await characteristics(service)).map((c) => [c.uuid.toLowerCase(), c])); + if (!chars.has(DEVICE_ID) || !chars.has(IDENTITY)) continue; + let tag = null; + try { + tag = chars.has(APP_TAG) ? bytesOf(await chars.get(APP_TAG).readValue()) : null; + } catch { + tag = null; + } + candidates.push({ service, tag: tag?.length ? tag : null }); + } + if (candidates.length === 0) return services[0]; + const ours = candidates.find(({ tag }) => tag && tag.length === this.#tag.length && tag.every((b, i) => b === this.#tag[i])); + if (ours) return ours.service; + const untagged = candidates.filter(({ tag }) => tag === null); + if (untagged.length === 1) return untagged[0].service; + return candidates[0].service; + } + + #usable() { + if (this.#released) throw new Error("this Bluetooth carrier was released"); + } + + #write(peer, bytes) { + const link = this.#links.get(peer); + if (!link) return; + link.queue = link.queue + .then(() => link.message.writeValueWithoutResponse(bytes)) + .catch(() => this.#drop(link)); + } + + // Disconnects `link` and reports it lost now, not on an event that a + // server already gone never sends. + #drop(link) { + if (link.device.gatt.connected) link.device.gatt.disconnect(); + this.#lost(link); + } + + // Removes both of a link's listeners. Every path that ends a link comes + // here: a notification listener left on a characteristic the browser hands + // out again would deliver each fragment once more per handshake. + #detach(link) { + link.device.removeEventListener("gattserverdisconnected", link.onDisconnect); + if (link.onNotify) link.message?.removeEventListener("characteristicvaluechanged", link.onNotify); + } + + // A link replaced by a newer one to the same address: closed without + // telling the engine, which keeps the peer on the newer link. + #retire(link) { + this.#detach(link); + if (link.announced) { + this.dispatchEvent(new CustomEvent("connection", { detail: { peer: link.peer, state: "closed" } })); + } + link.announced = false; + if (link.device !== this.#links.get(link.peer)?.device && link.device.gatt.connected) { + link.device.gatt.disconnect(); + } + } + + #lost(link) { + this.#detach(link); + if (this.#links.get(link.peer) !== link) return; + this.#links.delete(link.peer); + this.#node.bleLinkDown(link.peer); + if (link.announced) { + link.announced = false; + this.dispatchEvent(new CustomEvent("connection", { detail: { peer: link.peer, state: "closed" } })); + } + } + + // The node stopped: its worker is gone, so the links carry nothing. + #nodeStopped() { + for (const link of [...this.#links.values()]) { + this.#detach(link); + if (link.device.gatt.connected) link.device.gatt.disconnect(); + this.#links.delete(link.peer); + if (link.announced) { + this.dispatchEvent(new CustomEvent("connection", { detail: { peer: link.peer, state: "closed" } })); + } + } + this.#released = true; + } +} diff --git a/bindings/web/src/index.d.ts b/bindings/web/src/index.d.ts index 3a3d970f..3e2e715e 100644 --- a/bindings/web/src/index.d.ts +++ b/bindings/web/src/index.d.ts @@ -19,6 +19,8 @@ export interface StartOptions { tabs?: boolean; /** The Nostr transport over relays the application names (W12). */ nostr?: NostrOptions; + /** The Bluetooth LE slot, driven by a BluetoothCarrier on this page (W14). */ + ble?: boolean; /** Where worker.js is served, if not beside this module. */ workerUrl?: string | URL; /** @@ -176,6 +178,19 @@ export class OfflineNode extends EventTarget { freshUntilMs: number; } | null>; nearbyClose(id: number): Promise; + /** BluetoothCarrier's half in the worker (W14). */ + bleHello(): Promise; + bleAppTag(): Promise; + bleVerify(deviceId: Uint8Array, identity: Uint8Array): Promise; + bleLinkUp(peer: string, maxPayload: number): Promise; + bleFragmentReceived(peer: string, bytes: Uint8Array): Promise; + bleLinkDown(peer: string): Promise; + /** + * Where the engine's outbound Bluetooth fragments go; BluetoothCarrier sets + * it. `close` runs once when the node stops or fails. Throws when another + * carrier holds it; `null` gives it back. + */ + setBluetoothSink(sink: { write(peer: string, bytes: Uint8Array): void; close(): void } | null): void; /** Registers a carrier the page opened; resolves to its link id. */ openLink(carrier: PageCarrier): Promise; /** Bytes the carrier delivered; resolves to the reason it was closed, if it was. */ @@ -336,3 +351,38 @@ export function publicAddress( iceServers: RTCIceServer[], options?: { timeoutMs?: number; settleMs?: number }, ): Promise; + +/** + * A Bluetooth LE carrier over Web Bluetooth (W14): this page as a GATT + * central to phones and boards running the SDK as a peripheral. Chromium + * only. The page is never discoverable: every link starts from a person + * picking a device in the browser's chooser, inside a user gesture, or from a + * device this origin was granted before. Start the node with `ble: true`. + * A `connection` event carries `{ peer, state: "connected" | "closed" }`. + */ +export class BluetoothCarrier extends EventTarget { + /** + * `bluetooth` defaults to `navigator.bluetooth`. One carrier per node: + * throws while another carrier on `node` has not been released. + */ + constructor(node: OfflineNode, options?: { bluetooth?: unknown }); + /** Whether this browser exposes Web Bluetooth at all. */ + static readonly supported: boolean; + /** The addresses linked now. */ + readonly peers: string[]; + /** Opens the chooser and links the device picked; call inside a user gesture. */ + request(): Promise; + /** Devices this origin was granted before, where the browser remembers them. */ + known(): Promise; + /** + * Links a device; resolves to the address its identity proves. Rejects for + * a device that does not verify, serves no Hello, refuses the hello, or + * disconnects before the link is up. + */ + connect(device: unknown): Promise; + /** Closes the link to `peer` and reports it lost at once. */ + close(peer: string): void; + closeAll(): void; + /** Closes every link and gives the node's Bluetooth back to another carrier. */ + release(): void; +} diff --git a/bindings/web/src/index.js b/bindings/web/src/index.js index 94b7024e..2fc3cb58 100644 --- a/bindings/web/src/index.js +++ b/bindings/web/src/index.js @@ -3,6 +3,7 @@ export { WebRtcCarrier } from "./webrtc.js"; export { NostrSignaller } from "./nostr-signal.js"; export { NearbyRoom, publicAddress } from "./nearby.js"; +export { BluetoothCarrier } from "./bluetooth.js"; import { checkRelays } from "./nostr-relays.js"; export class OfflineNode extends EventTarget { @@ -15,6 +16,8 @@ export class OfflineNode extends EventTarget { #tabs; // link -> { send(bytes), close() }: the page's carriers. #links = new Map(); + // { write(peer, bytes), close() }: where Bluetooth fragments go (W14). + #bleSink = null; #onPageHide = () => this.#worker.postMessage({ id: 0, op: "stop" }); // A page restored from the back-forward cache had its node stopped on // pagehide; say so once rather than leave a node that answers nothing. @@ -51,12 +54,16 @@ export class OfflineNode extends EventTarget { /// none, so `relays` is required. `coldContact` (default on) publishes key /// packages so a device that knows only this node's address can reach it; /// `usernameDiscovery` (default off) publishes `profile` as a username. + /// + /// `ble: true` adds the Bluetooth LE slot, which a BluetoothCarrier on this + /// page drives over Web Bluetooth (W14): Chromium only, as a central. static async start({ appId, profile = "default", storage = "durable", tabs = true, nostr, + ble = false, workerUrl, wasm, } = {}) { @@ -81,6 +88,7 @@ export class OfflineNode extends EventTarget { tabs, instance, wasm, + ble: Boolean(ble), // The worker applies the defaults; it is the one place that does. nostr: nostr === undefined @@ -217,6 +225,46 @@ export class OfflineNode extends EventTarget { return this.#call("nearbyClose", { id }); } + /// The Bluetooth slot's half in the worker (W14), for BluetoothCarrier: + /// this device's hello, this application's App tag, a peripheral's Device + /// id and Identity checked (resolving to the address proved), a verified + /// link announced with its write payload, a notified fragment, a link gone. + bleHello() { + return this.#call("bleHello"); + } + + bleAppTag() { + return this.#call("bleAppTag"); + } + + bleVerify(deviceId, identity) { + return this.#call("bleVerify", { deviceId, identity }); + } + + bleLinkUp(peer, maxPayload) { + return this.#call("bleLinkUp", { peer, maxPayload }); + } + + bleFragmentReceived(peer, bytes) { + return this.#call("bleFragmentReceived", { peer, bytes }); + } + + bleLinkDown(peer) { + return this.#call("bleLinkDown", { peer }).catch(() => {}); + } + + /// Where the engine's outbound Bluetooth fragments go: + /// `{ write(peer, bytes), close() }`, `close` called once when the node + /// stops or fails. One sink per node, which BluetoothCarrier takes: + /// replacing a sink another carrier holds throws, since that carrier's + /// links would go deaf with no error. `null` gives it back. + setBluetoothSink(sink) { + if (sink !== null && this.#bleSink !== null) { + throw new Error("another BluetoothCarrier holds this node's Bluetooth; release() it first"); + } + this.#bleSink = sink; + } + /// What the durable store has cost since the node started (flushes, log /// and live bytes, compactions per log), or null for a node in memory. stats() { @@ -312,6 +360,13 @@ export class OfflineNode extends EventTarget { #closeAllCarriers() { for (const link of [...this.#links.keys()]) this.#closeCarrier(link); + const ble = this.#bleSink; + this.#bleSink = null; + try { + ble?.close(); + } catch { + // A carrier that throws on close is closed. + } } #call(op, args) { @@ -329,6 +384,8 @@ export class OfflineNode extends EventTarget { this.#pending.delete(message.id); if (message.error !== undefined) pending?.reject(new Error(message.error)); else pending?.resolve(message.result); + } else if (message.kind === "bleFragment") { + this.#bleSink?.write(message.peer, new Uint8Array(message.data)); } else if (message.kind === "frame") { this.#links.get(message.link)?.send(new Uint8Array(message.data)); } else if (message.kind === "linkClosed") { diff --git a/bindings/web/src/worker.js b/bindings/web/src/worker.js index 97976698..77061ffd 100644 --- a/bindings/web/src/worker.js +++ b/bindings/web/src/worker.js @@ -34,6 +34,7 @@ import init, { signalOpen, parseInvite, NearbyRoomKeys, + bleAppTag, } from "../pkg/offline_protocol_web.js"; import { openDurableStore } from "./store.js"; import { TabCarrier } from "./tab-carrier.js"; @@ -98,6 +99,14 @@ function flush() { if (pageLinks.has(link)) self.postMessage({ kind: "frame", link, data }, [data.buffer]); else carrier?.send(link, data); } + // Bluetooth fragments go to the page, which holds the GATT connections + // (W14). Transferred, as a carrier frame is. + for (let next = engine.bleNextFragment(); next; next = engine.bleNextFragment()) { + const peer = next.peer; + const data = next.data; + next.free(); + self.postMessage({ kind: "bleFragment", peer, data }, [data.buffer]); + } if (nostr?.live) { for (let next = engine.nostrNextEvent(); next !== undefined; next = engine.nostrNextEvent()) { nostr.publish(JSON.parse(next)); @@ -164,7 +173,7 @@ function nearbyRoom(id) { } const handlers = { - async start({ appId, profile, storage = "durable", tabs = true, instance, wasm, nostr: relays }) { + async start({ appId, profile, storage = "durable", tabs = true, instance, wasm, nostr: relays, ble = false }) { if (engine) throw new Error("this worker already runs a node"); const nostrRelays = relays === undefined ? undefined : checkRelays(relays.relays); // A host that cannot serve the module beside this file (an editor @@ -173,15 +182,14 @@ const handlers = { // Nothing is kept from a start that fails: no engine, and no lock or // handle, so the page can start the node again once the cause is gone. let address; - const next = - relays === undefined - ? new WebEngine(appId, profile) - : WebEngine.withNostr( - appId, - profile, - relays.coldContact ?? true, - relays.usernameDiscovery ?? false, - ); + const next = WebEngine.withOptions( + appId, + profile, + relays !== undefined, + relays?.coldContact ?? true, + relays?.usernameDiscovery ?? false, + Boolean(ble), + ); try { if (storage === "durable") { store = await openDurableStore(storageNamespace(appId, profile)); @@ -322,6 +330,26 @@ const handlers = { nearby.get(id)?.free(); nearby.delete(id); }, + // The Bluetooth slot's half in the worker (W14). The page holds the GATT + // connection and moves bytes; these verify, announce and reassemble. + bleHello() { + return engine.bleHello(); + }, + bleAppTag() { + return bleAppTag(startedAppId); + }, + bleVerify({ deviceId, identity }) { + return engine.bleVerify(deviceId, identity); + }, + bleLinkUp({ peer, maxPayload }) { + engine.bleLinkUp(peer, maxPayload); + }, + bleFragmentReceived({ peer, bytes }) { + engine.bleFragmentReceived(peer, bytes); + }, + bleLinkDown({ peer }) { + engine.bleLinkDown(peer); + }, sendGroupMessage({ groupId, content, ephemeral = false }) { return JSON.parse(engine.sendGroupMessage(groupId, content, Boolean(ephemeral))); }, diff --git a/bindings/web/test/bluetooth.html b/bindings/web/test/bluetooth.html new file mode 100644 index 00000000..7b1c4853 --- /dev/null +++ b/bindings/web/test/bluetooth.html @@ -0,0 +1,206 @@ + + +running +

+
diff --git a/bindings/web/test/fake-bluetooth.js b/bindings/web/test/fake-bluetooth.js
new file mode 100644
index 00000000..4e281e2f
--- /dev/null
+++ b/bindings/web/test/fake-bluetooth.js
@@ -0,0 +1,198 @@
+// A stand-in for navigator.bluetooth whose devices are SDK peripherals
+// played by real nodes, for the browser tests. No engine runs Web Bluetooth
+// without a person at the chooser, so the carrier is handed this instead,
+// and everything past the GATT calls (verification, the hello, the fragment
+// codec, the session) is the real engine on both sides.
+//
+// A peripheral here does what the shipped ones do: it serves the Device id
+// and Identity values its node gives it, takes a hello, attributes Message
+// writes to the central the hello named and nothing before, and notifies
+// the fragments its node queues for that central. It cannot verify a hello
+// on its own (the browser is never a peripheral, so the engine has no call
+// for it), so it is told the central's address and has its node check the
+// hello against that.
+
+const SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
+const MESSAGE = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
+const DEVICE_ID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
+const IDENTITY = "6e400004-b5a3-f393-e0a9-e50e24dcca9e";
+const APP_TAG = "6e400005-b5a3-f393-e0a9-e50e24dcca9e";
+const HELLO = "6e400006-b5a3-f393-e0a9-e50e24dcca9e";
+
+const view = (bytes) => new DataView(Uint8Array.from(bytes).buffer);
+
+class Characteristic extends EventTarget {
+  constructor(uuid, { value = null, onWrite = null } = {}) {
+    super();
+    this.uuid = uuid;
+    this.value = value === null ? null : view(value);
+    this.notifying = false;
+    this.writes = [];
+    this.onWrite = onWrite;
+  }
+
+  async readValue() {
+    if (this.value === null) throw new Error("not readable");
+    return this.value;
+  }
+
+  async writeValueWithResponse(bytes) {
+    this.writes.push(Uint8Array.from(bytes));
+    await this.onWrite?.(Uint8Array.from(bytes), { response: true });
+  }
+
+  async writeValueWithoutResponse(bytes) {
+    this.writes.push(Uint8Array.from(bytes));
+    await this.onWrite?.(Uint8Array.from(bytes), { response: false });
+  }
+
+  async startNotifications() {
+    this.notifying = true;
+    return this;
+  }
+
+  notify(bytes) {
+    if (!this.notifying) return;
+    this.value = view(bytes);
+    this.dispatchEvent(new Event("characteristicvaluechanged"));
+  }
+}
+
+class Service {
+  constructor(characteristics) {
+    this.uuid = SERVICE;
+    this.characteristics = characteristics;
+  }
+
+  async getCharacteristics() {
+    return this.characteristics;
+  }
+}
+
+class Device extends EventTarget {
+  constructor(name, services, { onDisconnect } = {}) {
+    super();
+    this.id = crypto.randomUUID();
+    this.name = name;
+    this.services = services;
+    const device = this;
+    this.gatt = {
+      connected: false,
+      device,
+      async connect() {
+        this.connected = true;
+        return this;
+      },
+      disconnect() {
+        if (!this.connected) return;
+        this.connected = false;
+        onDisconnect?.();
+        setTimeout(() => device.dispatchEvent(new Event("gattserverdisconnected")), 0);
+      },
+      async getPrimaryServices(uuid) {
+        return uuid === SERVICE ? device.services : [];
+      },
+    };
+  }
+}
+
+/// A device whose one instance of the service is `node`, a peripheral that
+/// expects the central at `centralAddress`. `options.servedId` overrides
+/// the Device id it serves (to serve one its identity does not prove);
+/// `options.extraInstances` puts other instances ahead of it;
+/// `options.noHello` serves no Hello characteristic; `options.refuseHello`
+/// rejects the hello write; `options.dropOnHello` disconnects as the hello
+/// arrives, mid-handshake.
+export async function peripheral(node, centralAddress, options = {}) {
+  const encoder = new TextEncoder();
+  let central = null;
+  // Every write to Message or Hello, in the order they arrived.
+  const order = [];
+  const message = new Characteristic(MESSAGE, {
+    onWrite: async (bytes) => {
+      order.push("message");
+      // Before a hello the shipped peripherals cannot attribute a write.
+      if (central) await node.bleFragmentReceived(central, bytes);
+    },
+  });
+  // A peripheral's stack answers one write before the next, so two hellos
+  // in flight are verified and linked one after the other.
+  let helloQueue = Promise.resolve();
+  const hello = new Characteristic(HELLO, {
+    onWrite: (bytes) => {
+      const handled = helloQueue.then(async () => {
+        order.push("hello");
+        if (options.refuseHello) throw new DOMException("hello refused", "NotSupportedError");
+        if (options.dropOnHello) {
+          device.gatt.disconnect();
+          return;
+        }
+        const named = await node.bleVerify(encoder.encode(centralAddress), bytes);
+        await node.bleLinkUp(named, 244);
+        central = named;
+      });
+      helloQueue = handled.catch(() => {});
+      return handled;
+    },
+  });
+  // The node plays a peripheral here, never a central, so its sink is this
+  // fixture's to take.
+  node.setBluetoothSink(null);
+  node.setBluetoothSink({
+    write: (peer, bytes) => {
+      if (peer === central) message.notify(bytes);
+    },
+    close: () => {},
+  });
+  const ours = new Service([
+    message,
+    new Characteristic(DEVICE_ID, { value: encoder.encode(options.servedId ?? node.address) }),
+    new Characteristic(IDENTITY, { value: await node.bleHello() }),
+    ...(options.tag ? [new Characteristic(APP_TAG, { value: options.tag })] : []),
+    ...(options.noHello ? [] : [hello]),
+  ]);
+  const device = new Device(`peripheral ${node.address.slice(0, 12)}`, [...(options.extraInstances ?? []), ours], {
+    onDisconnect: () => {
+      if (central) node.bleLinkDown(central);
+      central = null;
+    },
+  });
+  device.message = message;
+  device.hello = hello;
+  device.order = order;
+  return device;
+}
+
+/// An instance of the service that another SDK application on the same
+/// phone publishes: tagged with `tag`, serving `node`'s identity.
+export async function foreignInstance(node, tag) {
+  const encoder = new TextEncoder();
+  return new Service([
+    new Characteristic(MESSAGE),
+    new Characteristic(DEVICE_ID, { value: encoder.encode(node.address) }),
+    new Characteristic(IDENTITY, { value: await node.bleHello() }),
+    new Characteristic(APP_TAG, { value: tag }),
+    new Characteristic(HELLO),
+  ]);
+}
+
+/// navigator.bluetooth with `devices` in range: the chooser picks the first
+/// that offers the service the filter names.
+export class FakeBluetooth {
+  constructor(devices) {
+    this.devices = devices;
+    this.requests = [];
+  }
+
+  async requestDevice(options) {
+    this.requests.push(options);
+    const wanted = options.filters?.flatMap((f) => f.services ?? []) ?? [];
+    const found = this.devices.find((d) => d.services.length > 0 && wanted.includes(SERVICE));
+    if (!found) throw new DOMException("no device", "NotFoundError");
+    return found;
+  }
+
+  async getDevices() {
+    return this.devices;
+  }
+}
diff --git a/crates/offline-protocol-web/src/bindings.rs b/crates/offline-protocol-web/src/bindings.rs
index ca76fa78..84b06b44 100644
--- a/crates/offline-protocol-web/src/bindings.rs
+++ b/crates/offline-protocol-web/src/bindings.rs
@@ -170,6 +170,36 @@ impl OutboundFrame {
     }
 }
 
+/// One fragment for the page to write to a peripheral's Message
+/// characteristic.
+#[wasm_bindgen]
+pub struct BleFragment {
+    peer: String,
+    data: Vec,
+}
+
+#[wasm_bindgen]
+impl BleFragment {
+    /// The address whose link it goes on.
+    #[wasm_bindgen(getter)]
+    pub fn peer(&self) -> String {
+        self.peer.clone()
+    }
+
+    /// The fragment, written as one value.
+    #[wasm_bindgen(getter)]
+    pub fn data(&self) -> Vec {
+        self.data.clone()
+    }
+}
+
+/// This application's App tag (8 bytes), which a central compares when a
+/// peripheral presents one instance of the service per SDK application.
+#[wasm_bindgen(js_name = bleAppTag)]
+pub fn ble_app_tag(app_id: &str) -> Vec {
+    crate::ble_host::app_tag(app_id).to_vec()
+}
+
 /// The Offline Protocol engine, one per worker.
 #[wasm_bindgen]
 pub struct WebEngine {
@@ -189,22 +219,27 @@ impl WebEngine {
         })
     }
 
-    /// A stopped engine that also carries the Nostr slot (W12), which the
-    /// worker drives over relays the application named. `coldContact`
-    /// publishes key packages and resolves peers'; `usernameDiscovery`
-    /// publishes `profile` as a username and resolves others'.
-    #[wasm_bindgen(js_name = withNostr)]
-    pub fn with_nostr(
+    /// A stopped engine with the slots the application turned on. `nostr`
+    /// adds the Nostr slot (W12), which the worker drives over relays the
+    /// application named: `coldContact` publishes key packages and resolves
+    /// peers', `usernameDiscovery` publishes `profile` as a username and
+    /// resolves others'. `ble` adds the Bluetooth LE slot (W14), driven from
+    /// the page over Web Bluetooth.
+    #[wasm_bindgen(js_name = withOptions)]
+    pub fn with_options(
         app_id: &str,
         profile: &str,
+        nostr: bool,
         cold_contact: bool,
         username_discovery: bool,
+        ble: bool,
     ) -> Result {
         let options = NodeOptions {
-            nostr: Some(NostrOptions {
+            nostr: nostr.then_some(NostrOptions {
                 cold_contact,
                 username_discovery,
             }),
+            ble,
         };
         Ok(Self {
             node: Node::with_options(app_id, profile, options).map_err(js)?,
@@ -459,6 +494,47 @@ impl WebEngine {
         self.node.nostr_relay_event(relay_message).map_err(js)
     }
 
+    /// This device's identity assertion, which the page writes to a
+    /// peripheral's Hello characteristic (W14).
+    #[wasm_bindgen(js_name = bleHello)]
+    pub fn ble_hello(&self) -> Result, JsError> {
+        self.node.ble_hello().map_err(js)
+    }
+
+    /// Checks a peripheral's Device id and Identity values; returns the
+    /// address the identity proves, which must equal the Device id.
+    #[wasm_bindgen(js_name = bleVerify)]
+    pub fn ble_verify(&mut self, device_id: &[u8], identity: &[u8]) -> Result {
+        self.node.ble_verify(device_id, identity).map_err(js)
+    }
+
+    /// A verified peripheral's link is subscribed and has said hello; its
+    /// write payload is `maxPayload` bytes.
+    #[wasm_bindgen(js_name = bleLinkUp)]
+    pub fn ble_link_up(&mut self, peer: &str, max_payload: u32) -> Result<(), JsError> {
+        self.node.ble_link_up(peer, max_payload).map_err(js)
+    }
+
+    /// One fragment a peripheral notified on `peer`'s link.
+    #[wasm_bindgen(js_name = bleFragmentReceived)]
+    pub fn ble_fragment_received(&mut self, peer: &str, fragment: &[u8]) -> Result<(), JsError> {
+        self.node.ble_fragment_received(peer, fragment).map_err(js)
+    }
+
+    /// The next fragment to write, or `undefined`.
+    #[wasm_bindgen(js_name = bleNextFragment)]
+    pub fn ble_next_fragment(&mut self) -> Option {
+        self.node
+            .ble_next_fragment()
+            .map(|(peer, data)| BleFragment { peer, data })
+    }
+
+    /// The link to `peer` is gone.
+    #[wasm_bindgen(js_name = bleLinkDown)]
+    pub fn ble_link_down(&mut self, peer: &str) {
+        self.node.ble_link_down(peer);
+    }
+
     /// Sends to a group; returns the per-member message ids as JSON.
     /// `ephemeral` as for [`WebEngine::send_message`].
     #[wasm_bindgen(js_name = sendGroupMessage)]
diff --git a/crates/offline-protocol-web/src/ble_host.rs b/crates/offline-protocol-web/src/ble_host.rs
new file mode 100644
index 00000000..55c8a1b5
--- /dev/null
+++ b/crates/offline-protocol-web/src/ble_host.rs
@@ -0,0 +1,294 @@
+//! The Bluetooth LE slot, driven from the page over Web Bluetooth (W14).
+//!
+//! A page can be a GATT central and nothing else: it cannot advertise, so
+//! nothing ever discovers it, and it serves no GATT, so a peripheral can never
+//! dial it back. What it can do, in Chromium, is connect to a phone or a board
+//! running this SDK as a peripheral once its person picks the device in the
+//! browser's chooser. The page owns that connection and moves bytes; this
+//! module owns what they mean, through the `BleTransport` every binding
+//! shares, so the fragment codec is the one the conformance vectors check.
+//!
+//! # Invariants
+//!
+//! - **A peer is announced only under the address its identity proves.** The
+//!   page hands over the Device id and Identity values it read;
+//!   [`Node::ble_verify`] runs the one verifier, compares the derived address
+//!   with the Device id exactly, and remembers it. [`Node::ble_link_up`]
+//!   announces only an address that verification produced, so a page cannot
+//!   name a peer the engine did not check.
+//! - **The per-peer MTU reaches the transport before the peer does,** as in
+//!   every binding: a fragmenting send between the two would fall back to the
+//!   floor. A value under the 185-byte floor is the transport's to refuse.
+//! - **A peer is announced once and lost once.** A second link-up for an
+//!   address already linked (the page replaced a link to the same device)
+//!   updates its write payload and announces nothing. A peer linked over a
+//!   peer stream as well as Bluetooth is lost only when the last of them
+//!   goes, as the FFI's `ble_peer_lost` does, and [`Node::stop`] reports
+//!   every Bluetooth link lost before the engine stops.
+//! - **The engine hears of a neighbour on a message, not on a fragment.**
+//!   Its neighbour hook flushes the outbox, redelivers custody and kicks
+//!   sync, which is work per message; the FFI's `ble_fragment_received` does
+//!   not call it at all.
+//! - **The central says hello.** [`Node::ble_hello`] is this device's
+//!   identity assertion, which the page writes to the peripheral's Hello
+//!   characteristic: without it a peripheral has no way to name a central it
+//!   cannot dial back, and nothing the page writes is attributed.
+
+use std::collections::{BTreeSet, VecDeque};
+use std::sync::Arc;
+
+use offline_protocol::Event;
+use offline_protocol_core::time::SystemTime;
+use offline_protocol_transport::ble::PeerDevice;
+use offline_protocol_transport::{BleTransport, Transport, TransportType};
+use sha2::{Digest, Sha256};
+use tracing::warn;
+
+use crate::node::{push_event, Node, NodeError, NodeResult, MAX_LINKS};
+
+/// Addresses verified and waiting to be linked, at most. A page verifies a
+/// device and links it moments later; this bounds a page that only verifies.
+const MAX_VERIFIED: usize = 16;
+
+/// The App tag domain separator, the iOS and Android copies' own.
+const APP_TAG_DOMAIN: &str = "offline-protocol-ble-app-tag-v1";
+
+/// Bytes in an App tag.
+pub const APP_TAG_BYTES: usize = 8;
+
+/// This application's App tag: `SHA-256(domain ‖ 0x00 ‖ app_id)[0..8]`, the
+/// value a central compares when a peripheral presents several instances of
+/// the service, one per SDK application on the phone.
+pub fn app_tag(app_id: &str) -> [u8; APP_TAG_BYTES] {
+    let mut hasher = Sha256::new();
+    hasher.update(APP_TAG_DOMAIN.as_bytes());
+    hasher.update([0u8]);
+    hasher.update(app_id.as_bytes());
+    let digest = hasher.finalize();
+    let mut tag = [0u8; APP_TAG_BYTES];
+    tag.copy_from_slice(&digest[..APP_TAG_BYTES]);
+    tag
+}
+
+/// The worker's side of the Bluetooth slot.
+#[derive(Debug, Default)]
+pub(crate) struct BleHost {
+    /// Addresses `ble_verify` proved, not linked yet.
+    verified: VecDeque,
+    /// Addresses linked over Bluetooth now.
+    pub(crate) linked: BTreeSet,
+}
+
+impl Node {
+    /// This device's identity assertion, for the page to write to a
+    /// peripheral's Hello characteristic.
+    pub fn ble_hello(&self) -> NodeResult> {
+        let manager = self
+            .protocol
+            .mls_manager()
+            .ok_or_else(|| NodeError("initialize before linking over Bluetooth".to_string()))?;
+        let assertion = manager
+            .read()
+            .map_err(|_| NodeError("MLS manager lock poisoned".to_string()))?
+            .identity_assertion(&[])
+            .map_err(|e| NodeError(e.to_string()))?;
+        Ok(assertion)
+    }
+
+    /// Checks what a peripheral served: its Device id value and its Identity
+    /// value. Returns the address the identity proves, which must equal the
+    /// Device id exactly, and remembers it for [`Self::ble_link_up`].
+    pub fn ble_verify(&mut self, device_id: &[u8], identity: &[u8]) -> NodeResult {
+        self.ble_host_mut()?;
+        let claimed = std::str::from_utf8(device_id)
+            .map_err(|_| NodeError("the peripheral's Device id is not UTF-8".to_string()))?;
+        let derived = offline_protocol_mls::verify_identity_assertion(identity)
+            .map_err(|e| NodeError(format!("the peripheral's identity did not verify: {e}")))?
+            .to_string();
+        if derived != claimed {
+            return Err(NodeError(format!(
+                "the peripheral serves Device id {claimed} but proves {derived}"
+            )));
+        }
+        if self.protocol.local_address() == Some(derived.as_str()) {
+            return Err(NodeError(
+                "the peripheral proves this device's own address".to_string(),
+            ));
+        }
+        let host = self.ble_host_mut()?;
+        host.verified.retain(|held| held != &derived);
+        if host.verified.len() >= MAX_VERIFIED {
+            host.verified.pop_front();
+        }
+        host.verified.push_back(derived.clone());
+        Ok(derived)
+    }
+
+    /// The link to `peer`, which [`Self::ble_verify`] proved, is subscribed
+    /// and has said hello: tells the transport its write payload, then the
+    /// engine that the peer is here.
+    pub fn ble_link_up(&mut self, peer: &str, max_payload: u32) -> NodeResult<()> {
+        let host = self.ble_host_mut()?;
+        let Some(at) = host.verified.iter().position(|held| held == peer) else {
+            return Err(NodeError(format!(
+                "{peer} was not verified on this link; verify its identity first"
+            )));
+        };
+        if !host.linked.contains(peer) && host.linked.len() >= MAX_LINKS {
+            return Err(NodeError(format!(
+                "{MAX_LINKS} Bluetooth links are already open"
+            )));
+        }
+        host.verified.remove(at);
+        let relinked = !host.linked.insert(peer.to_string());
+        let transport = self.ble_slot()?;
+        // ORDERING: the payload before the peer, as every binding does.
+        transport.set_peer_mtu(peer, max_payload as usize);
+        if relinked {
+            // The page replaced its link to this address. The peer never
+            // left, so it is not announced a second time.
+            return Ok(());
+        }
+        if let Some(ble) = transport.as_any().downcast_ref::() {
+            ble.on_peer_discovered(PeerDevice {
+                device_id: peer.to_string(),
+                address: String::new(),
+                rssi: 0,
+                last_seen: SystemTime::now(),
+                connected: true,
+            });
+        }
+        let blocked = self.protocol.is_user_blocked(peer);
+        self.protocol.on_neighbor_discovered(peer);
+        if !blocked {
+            push_event(
+                &self.events,
+                &Event::NeighborDiscovered {
+                    peer_id: peer.to_string(),
+                    transport: "BLE".to_string(),
+                    rssi: None,
+                },
+            );
+        }
+        Ok(())
+    }
+
+    /// A notification or indication the peripheral sent on `peer`'s link:
+    /// one fragment, reassembled in the transport.
+    pub fn ble_fragment_received(&mut self, peer: &str, fragment: &[u8]) -> NodeResult<()> {
+        if !self.ble_host_mut()?.linked.contains(peer) {
+            return Err(NodeError(format!("{peer} has no Bluetooth link")));
+        }
+        let transport = self.ble_slot()?;
+        transport
+            .on_fragment_received_from(fragment.to_vec(), peer.to_string())
+            .map_err(|e| NodeError(e.to_string()))?;
+        let mut delivered = false;
+        while self.protocol.receive_message().is_some() {
+            delivered = true;
+        }
+        // Drained first, as on a peer stream, so a handshake confirmation in
+        // this message lands before the engine decides whether to re-arm a
+        // Welcome. Only on a whole message: a fragment says nothing new.
+        if delivered {
+            self.protocol.on_neighbor_discovered(peer);
+        }
+        Ok(())
+    }
+
+    /// The next fragment to write, as `(peer, bytes)`. A fragment for a peer
+    /// whose link went down after it was queued is dropped.
+    pub fn ble_next_fragment(&mut self) -> Option<(String, Vec)> {
+        let transport = self.ble_slot().ok()?;
+        loop {
+            let (peer, fragment) = match transport.get_next_fragment() {
+                Ok(Some(next)) => next,
+                Ok(None) => return None,
+                Err(error) => {
+                    warn!(%error, "bluetooth: could not take an outbound fragment");
+                    return None;
+                }
+            };
+            if self.ble_host.as_ref()?.linked.contains(&peer) {
+                return Some((peer, fragment));
+            }
+            warn!(%peer, "bluetooth: dropping a fragment for a link that closed");
+        }
+    }
+
+    /// The link to `peer` is gone. The engine hears of the loss only when no
+    /// peer stream still links it.
+    pub fn ble_link_down(&mut self, peer: &str) {
+        let Some(host) = self.ble_host.as_mut() else {
+            return;
+        };
+        if !host.linked.remove(peer) {
+            return;
+        }
+        if let Ok(transport) = self.ble_slot() {
+            if let Some(ble) = transport.as_any().downcast_ref::() {
+                ble.on_peer_lost(peer);
+            }
+        }
+        if self.stream_linked(peer) {
+            return;
+        }
+        self.protocol.on_neighbor_lost(peer);
+        push_event(
+            &self.events,
+            &Event::NeighborLost {
+                peer_id: peer.to_string(),
+            },
+        );
+    }
+
+    /// Reports every Bluetooth link lost, for [`Node::stop`].
+    pub(crate) fn ble_links_down(&mut self) {
+        let linked: Vec = self
+            .ble_host
+            .as_ref()
+            .map(|host| host.linked.iter().cloned().collect())
+            .unwrap_or_default();
+        for peer in linked {
+            self.ble_link_down(&peer);
+        }
+    }
+
+    /// Whether `peer` is linked over Bluetooth now.
+    pub(crate) fn ble_linked(&self, peer: &str) -> bool {
+        self.ble_host
+            .as_ref()
+            .is_some_and(|host| host.linked.contains(peer))
+    }
+
+    fn ble_host_mut(&mut self) -> NodeResult<&mut BleHost> {
+        self.ble_host
+            .as_mut()
+            .ok_or_else(|| NodeError("Bluetooth is not enabled on this node".to_string()))
+    }
+
+    fn ble_slot(&self) -> NodeResult> {
+        self.protocol
+            .transport_manager()
+            .get_transport(TransportType::BLE)
+            .ok_or_else(|| NodeError("Bluetooth is not enabled on this node".to_string()))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn hex(bytes: &[u8]) -> String {
+        bytes.iter().map(|b| format!("{b:02x}")).collect()
+    }
+
+    /// The vectors iOS and Android pin (`BleAppTagTests.swift`,
+    /// `BleAppTagTest.kt`): only shared vectors prove the copies agree.
+    #[test]
+    fn the_app_tag_matches_the_native_vectors() {
+        assert_eq!(hex(&app_tag("offline-messenger")), "2e2e6107567ff67b");
+        assert_eq!(hex(&app_tag("offline-demo")), "3a88052657e598e1");
+        assert_eq!(hex(&app_tag("")), "d5c9ed34fbf6503f");
+    }
+}
diff --git a/crates/offline-protocol-web/src/ble_tests.rs b/crates/offline-protocol-web/src/ble_tests.rs
new file mode 100644
index 00000000..4c1d7b02
--- /dev/null
+++ b/crates/offline-protocol-web/src/ble_tests.rs
@@ -0,0 +1,337 @@
+//! Two engines linked over their Bluetooth LE slots, on one host.
+//!
+//! Each side plays its half of the GATT contract by hand: the central reads
+//! the peripheral's Device id and Identity and verifies them, the peripheral
+//! verifies the central's hello, and every fragment either side queues is
+//! handed to the other as a write or a notification would carry it. The
+//! fragment codec runs on both sides, as it does between a browser and a
+//! phone.
+
+use std::time::Duration;
+
+use serde_json::Value;
+
+use crate::node::{LinkId, Node, NodeOptions, MAX_LINKS};
+
+struct Side {
+    node: Node,
+    address: String,
+    events: Vec,
+}
+
+fn side(profile: &str) -> Side {
+    let options = NodeOptions {
+        ble: true,
+        ..Default::default()
+    };
+    let mut node = Node::with_options("web-ble-test", profile, options).expect("node");
+    let address = node.initialize().expect("initialize");
+    node.start().expect("start");
+    Side {
+        node,
+        address,
+        events: Vec::new(),
+    }
+}
+
+/// What a page does once a link is subscribed and the hello is written: the
+/// central verifies what the peripheral served, the peripheral verifies the
+/// hello, and each tells its engine the link is up.
+fn link(central: &mut Side, peripheral: &mut Side, payload: u32) {
+    let identity = peripheral.node.ble_hello().expect("identity");
+    let proved = central
+        .node
+        .ble_verify(peripheral.address.as_bytes(), &identity)
+        .expect("the peripheral verifies");
+    assert_eq!(proved, peripheral.address);
+    central.node.ble_link_up(&proved, payload).expect("link up");
+
+    let hello = central.node.ble_hello().expect("hello");
+    let named = peripheral
+        .node
+        .ble_verify(central.address.as_bytes(), &hello)
+        .expect("the hello verifies");
+    peripheral
+        .node
+        .ble_link_up(&named, payload)
+        .expect("link up");
+}
+
+fn pump(a: &mut Side, b: &mut Side) {
+    while let Some((peer, fragment)) = a.node.ble_next_fragment() {
+        assert_eq!(peer, b.address);
+        b.node
+            .ble_fragment_received(&a.address, &fragment)
+            .expect("fragment");
+    }
+    while let Some((peer, fragment)) = b.node.ble_next_fragment() {
+        assert_eq!(peer, a.address);
+        a.node
+            .ble_fragment_received(&b.address, &fragment)
+            .expect("fragment");
+    }
+    for s in [&mut *a, &mut *b] {
+        s.node.process().expect("process");
+        while let Some(json) = s.node.poll_event() {
+            s.events
+                .push(serde_json::from_str(&json).expect("event json"));
+        }
+    }
+}
+
+fn run_until(a: &mut Side, b: &mut Side, mut done: impl FnMut(&Side, &Side) -> bool) -> bool {
+    for _ in 0..400 {
+        pump(a, b);
+        if done(a, b) {
+            return true;
+        }
+        std::thread::sleep(Duration::from_millis(25));
+    }
+    false
+}
+
+#[test]
+fn a_central_and_a_peripheral_form_a_session_and_exchange_a_message() {
+    let (mut a, mut b) = (side("browser"), side("phone"));
+    link(&mut a, &mut b, 244);
+    let (a_addr, b_addr) = (a.address.clone(), b.address.clone());
+    assert!(run_until(&mut a, &mut b, |a, b| {
+        a.events
+            .iter()
+            .any(|e| e["type"] == "secure_session_established" && e["peer_id"] == b_addr.as_str())
+            && b.events.iter().any(|e| {
+                e["type"] == "secure_session_established" && e["peer_id"] == a_addr.as_str()
+            })
+    }));
+    assert!(a
+        .events
+        .iter()
+        .any(|e| e["type"] == "neighbor_discovered" && e["transport"] == "BLE"));
+    // Longer than one 244-byte fragment, so it crosses in several.
+    let long = "over Bluetooth ".repeat(40);
+    a.node.send_message(&b_addr, &long, false).expect("send");
+    assert!(run_until(&mut a, &mut b, |_, b| {
+        b.events.iter().any(|e| {
+            e["type"] == "message_received"
+                && e["content"] == long.as_str()
+                && e["encrypted"] == true
+        })
+    }));
+}
+
+#[test]
+fn a_peripheral_whose_device_id_is_not_what_it_proves_is_refused() {
+    let (mut a, b) = (side("browser"), side("phone"));
+    let c = side("other");
+    let identity = b.node.ble_hello().expect("identity");
+    assert!(a.node.ble_verify(c.address.as_bytes(), &identity).is_err());
+    assert!(a.node.ble_verify(&[0xff, 0xfe], &identity).is_err());
+    let mut forged = identity.clone();
+    let last = forged.len() - 1;
+    forged[last] ^= 1;
+    assert!(a.node.ble_verify(b.address.as_bytes(), &forged).is_err());
+    let own = a.node.ble_hello().expect("own");
+    let own_address = a.address.clone();
+    assert!(
+        a.node.ble_verify(own_address.as_bytes(), &own).is_err(),
+        "this device's own identity, replayed, names no peer"
+    );
+}
+
+#[test]
+fn only_a_verified_address_is_linked_and_only_a_linked_one_delivers() {
+    let (mut a, b) = (side("browser"), side("phone"));
+    assert!(
+        a.node.ble_link_up(&b.address, 244).is_err(),
+        "a page must not announce an address the engine did not verify"
+    );
+    assert!(a.node.ble_fragment_received(&b.address, b"OP").is_err());
+    assert!(a.node.ble_next_fragment().is_none());
+}
+
+#[test]
+fn a_link_that_goes_down_reports_the_peer_lost_once() {
+    let (mut a, mut b) = (side("browser"), side("phone"));
+    link(&mut a, &mut b, 244);
+    let b_addr = b.address.clone();
+    pump(&mut a, &mut b);
+    // A disconnect is seen at both ends; Alice's page reports it twice.
+    let a_addr = a.address.clone();
+    a.node.ble_link_down(&b_addr);
+    a.node.ble_link_down(&b_addr);
+    b.node.ble_link_down(&a_addr);
+    pump(&mut a, &mut b);
+    let lost = a
+        .events
+        .iter()
+        .filter(|e| e["type"] == "neighbor_lost" && e["peer_id"] == b_addr.as_str())
+        .count();
+    assert_eq!(lost, 1);
+    assert!(
+        a.node.ble_fragment_received(&b_addr, b"OP").is_err(),
+        "a link that went down delivers nothing more"
+    );
+}
+
+#[test]
+fn a_node_without_bluetooth_has_no_slot() {
+    let mut node = Node::new("web-ble-test", "plain").expect("node");
+    node.initialize().expect("initialize");
+    assert!(node.ble_verify(b"x", b"y").is_err());
+    assert!(node.ble_next_fragment().is_none());
+}
+
+/// Collects the events a side has queued.
+fn drain(s: &mut Side) {
+    while let Some(json) = s.node.poll_event() {
+        s.events
+            .push(serde_json::from_str(&json).expect("event json"));
+    }
+}
+
+fn count(s: &Side, kind: &str, peer: &str) -> usize {
+    s.events
+        .iter()
+        .filter(|e| e["type"] == kind && e["peer_id"] == peer)
+        .count()
+}
+
+/// Verifies `peripheral` and links it on `central` only, as a page does
+/// when it opens a link to a device.
+fn central_link(central: &mut Side, peripheral: &Side, payload: u32) -> Result<(), String> {
+    let identity = peripheral.node.ble_hello().expect("identity");
+    let proved = central
+        .node
+        .ble_verify(peripheral.address.as_bytes(), &identity)
+        .map_err(|e| e.to_string())?;
+    central
+        .node
+        .ble_link_up(&proved, payload)
+        .map_err(|e| e.to_string())
+}
+
+/// Opens a peer stream between the two, as a tab or a data channel would,
+/// and moves its preambles across, so each announces the other on it.
+fn stream(a: &mut Side, b: &mut Side) -> (LinkId, LinkId) {
+    let (la, lb) = (
+        a.node.open_link().expect("link"),
+        b.node.open_link().expect("link"),
+    );
+    for _ in 0..4 {
+        while let Some((link, data)) = a.node.next_outbound() {
+            assert_eq!(link, la);
+            assert_eq!(b.node.link_received(lb, &data), None);
+        }
+        while let Some((link, data)) = b.node.next_outbound() {
+            assert_eq!(link, lb);
+            assert_eq!(a.node.link_received(la, &data), None);
+        }
+    }
+    (la, lb)
+}
+
+#[test]
+fn a_replaced_link_to_the_same_address_is_not_announced_again() {
+    let (mut a, b) = (side("browser"), side("phone"));
+    let b_addr = b.address.clone();
+    central_link(&mut a, &b, 244).expect("first link");
+    central_link(&mut a, &b, 300).expect("the page replaced its link");
+    drain(&mut a);
+    assert_eq!(count(&a, "neighbor_discovered", &b_addr), 1);
+    assert_eq!(count(&a, "neighbor_lost", &b_addr), 0);
+    a.node.ble_link_down(&b_addr);
+    drain(&mut a);
+    assert_eq!(count(&a, "neighbor_lost", &b_addr), 1);
+}
+
+#[test]
+fn a_peer_also_linked_over_a_stream_is_lost_only_with_its_last_link() {
+    // Bluetooth goes first.
+    let (mut a, mut b) = (side("browser"), side("phone"));
+    let b_addr = b.address.clone();
+    central_link(&mut a, &b, 244).expect("link");
+    let (la, _) = stream(&mut a, &mut b);
+    drain(&mut a);
+    a.node.ble_link_down(&b_addr);
+    drain(&mut a);
+    assert_eq!(
+        count(&a, "neighbor_lost", &b_addr),
+        0,
+        "the stream still reaches the peer"
+    );
+    a.node.close_link(la);
+    drain(&mut a);
+    assert_eq!(count(&a, "neighbor_lost", &b_addr), 1);
+
+    // The stream goes first.
+    let (mut c, mut d) = (side("browser-two"), side("phone-two"));
+    let d_addr = d.address.clone();
+    let (lc, _) = stream(&mut c, &mut d);
+    central_link(&mut c, &d, 244).expect("link");
+    drain(&mut c);
+    c.node.close_link(lc);
+    drain(&mut c);
+    assert_eq!(
+        count(&c, "neighbor_lost", &d_addr),
+        0,
+        "Bluetooth still reaches the peer"
+    );
+    c.node.ble_link_down(&d_addr);
+    drain(&mut c);
+    assert_eq!(count(&c, "neighbor_lost", &d_addr), 1);
+}
+
+#[test]
+fn links_past_the_ceiling_are_refused() {
+    let mut a = side("browser");
+    let devices: Vec = (0..=MAX_LINKS)
+        .map(|n| side(&format!("phone-{n}")))
+        .collect();
+    for device in &devices[..MAX_LINKS] {
+        central_link(&mut a, device, 244).expect("under the ceiling");
+    }
+    let refused = central_link(&mut a, &devices[MAX_LINKS], 244).expect_err("over the ceiling");
+    assert!(refused.contains("already open"), "{refused}");
+    a.node.ble_link_down(&devices[0].address);
+    central_link(&mut a, &devices[MAX_LINKS], 244).expect("a slot freed");
+}
+
+#[test]
+fn a_page_that_only_verifies_holds_a_bounded_list() {
+    let mut a = side("browser");
+    let devices: Vec = (0..=16).map(|n| side(&format!("phone-{n}"))).collect();
+    for device in &devices {
+        let identity = device.node.ble_hello().expect("identity");
+        a.node
+            .ble_verify(device.address.as_bytes(), &identity)
+            .expect("verify");
+    }
+    assert!(
+        a.node.ble_link_up(&devices[0].address, 244).is_err(),
+        "the oldest verification was evicted"
+    );
+    a.node
+        .ble_link_up(&devices[16].address, 244)
+        .expect("the newest is held");
+}
+
+#[test]
+fn a_blocked_peer_is_linked_but_not_announced() {
+    let (mut a, b) = (side("browser"), side("phone"));
+    let b_addr = b.address.clone();
+    a.node.protocol.block_user(&b_addr).expect("block");
+    central_link(&mut a, &b, 244).expect("link");
+    drain(&mut a);
+    assert_eq!(count(&a, "neighbor_discovered", &b_addr), 0);
+}
+
+#[test]
+fn stopping_reports_every_bluetooth_peer_lost() {
+    let (mut a, b) = (side("browser"), side("phone"));
+    let b_addr = b.address.clone();
+    central_link(&mut a, &b, 244).expect("link");
+    a.node.stop().expect("stop");
+    drain(&mut a);
+    assert_eq!(count(&a, "neighbor_lost", &b_addr), 1);
+    assert!(a.node.ble_fragment_received(&b_addr, b"OP").is_err());
+}
diff --git a/crates/offline-protocol-web/src/lib.rs b/crates/offline-protocol-web/src/lib.rs
index e5d5211b..c85382c1 100644
--- a/crates/offline-protocol-web/src/lib.rs
+++ b/crates/offline-protocol-web/src/lib.rs
@@ -13,7 +13,8 @@
 //! connection codes through Nostr relays instead of by hand, and
 //! [`nostr_host`] drives the Nostr transport itself from the worker, with
 //! relays the application names. [`nearby`] lets the browsers behind one
-//! network address find each other there.
+//! network address find each other there. [`ble_host`] drives the
+//! Bluetooth LE slot from a page that is a GATT central over Web Bluetooth.
 //!
 //! The crate also builds on every host, so the workspace's lint, docs and test
 //! runs cover it: everything below the bindings is plain Rust, and the tests
@@ -23,6 +24,7 @@
 #![warn(missing_docs)]
 
 pub mod bindings;
+pub mod ble_host;
 pub mod log_store;
 pub mod memory_store;
 pub mod nearby;
@@ -31,6 +33,8 @@ pub mod nostr_host;
 pub mod opfs;
 pub mod signal;
 
+#[cfg(test)]
+mod ble_tests;
 #[cfg(test)]
 mod nostr_tests;
 #[cfg(test)]
diff --git a/crates/offline-protocol-web/src/node.rs b/crates/offline-protocol-web/src/node.rs
index 5029f2c2..83c080d7 100644
--- a/crates/offline-protocol-web/src/node.rs
+++ b/crates/offline-protocol-web/src/node.rs
@@ -44,11 +44,12 @@ use offline_protocol::{
 };
 use offline_protocol_core::time::{Duration, Instant};
 use offline_protocol_transport::{
-    frame, NostrTransport, PeerStreamReader, StreamEvent, Transport, TransportStatus,
+    frame, BleTransport, NostrTransport, PeerStreamReader, StreamEvent, Transport, TransportStatus,
     TransportType, WifiDirectTransport,
 };
 use tracing::warn;
 
+use crate::ble_host::BleHost;
 use crate::memory_store::MemoryProtocolStateStorage;
 use crate::nostr_host::{NostrHost, NostrOptions};
 
@@ -118,6 +119,8 @@ impl SessionStart {
 pub struct NodeOptions {
     /// The Nostr slot, when the application gives the node relays.
     pub nostr: Option,
+    /// The Bluetooth LE slot, driven from the page over Web Bluetooth.
+    pub ble: bool,
 }
 
 struct Link {
@@ -134,6 +137,8 @@ pub struct Node {
     pub(crate) events: Arc>>,
     /// The worker's half of the Nostr slot, when the node has one.
     pub(crate) nostr_host: Option,
+    /// The worker's half of the Bluetooth slot, when the node has one.
+    pub(crate) ble_host: Option,
     links: BTreeMap,
     /// The one live link for each announced address.
     peers: HashMap,
@@ -157,11 +162,12 @@ impl Node {
     /// The peer-stream slot is always registered. The Nostr slot is
     /// registered once the address exists, when `options.nostr` is set: the
     /// transport derives its routing tag from the address it is built with.
-    /// A browser has no Bluetooth peripheral, and the internet relay's socket
-    /// is not wired.
+    /// The Bluetooth slot is registered with it when `options.ble` is set:
+    /// the page is a central over Web Bluetooth, never a peripheral. The
+    /// internet relay's socket is not wired.
     pub fn with_options(app_id: &str, profile: &str, options: NodeOptions) -> NodeResult {
         let mut config = ProtocolConfig::new(app_id, profile);
-        config.transport.ble_enabled = false;
+        config.transport.ble_enabled = options.ble;
         config.transport.internet_enabled = false;
         config.transport.reticulum_enabled = false;
         config.transport.nostr_enabled = options.nostr.is_some();
@@ -187,6 +193,7 @@ impl Node {
             protocol,
             events,
             nostr_host: options.nostr.map(|_| NostrHost::default()),
+            ble_host: options.ble.then(BleHost::default),
             links: BTreeMap::new(),
             peers: HashMap::new(),
             outbound: VecDeque::new(),
@@ -239,6 +246,12 @@ impl Node {
             TransportType::WiFiDirect,
             Box::new(WifiDirectTransport::new(address.as_str())),
         );
+        if self.ble_host.is_some() {
+            self.protocol.transport_manager_mut().add_transport(
+                TransportType::BLE,
+                Box::new(BleTransport::new(address.as_str())),
+            );
+        }
         if let Some(nostr) = nostr {
             self.protocol
                 .transport_manager_mut()
@@ -260,16 +273,29 @@ impl Node {
         if let Some(transport) = self.slot() {
             transport.on_status_changed(TransportStatus::Available);
         }
+        // Up as the peer-stream slot is: a link is announced only once its
+        // peripheral's identity verified, so "up" promises no peer.
+        if self.ble_host.is_some() {
+            if let Some(transport) = self
+                .protocol
+                .transport_manager()
+                .get_transport(TransportType::BLE)
+            {
+                transport.on_status_changed(TransportStatus::Available);
+            }
+        }
         Ok(())
     }
 
-    /// Stops the engine. Every link is reported lost first, so the engine's
-    /// view of who is reachable ends empty rather than stale.
+    /// Stops the engine. Every link is reported lost first, peer streams and
+    /// Bluetooth alike, so the engine's view of who is reachable ends empty
+    /// rather than stale.
     pub fn stop(&mut self) -> NodeResult<()> {
         let links: Vec = self.links.keys().copied().collect();
         for link in links {
             self.close_link(link);
         }
+        self.ble_links_down();
         self.protocol.stop()?;
         Ok(())
     }
@@ -382,6 +408,10 @@ impl Node {
         if let Some(transport) = self.wifi_direct() {
             transport.on_peer_disconnected(&peer);
         }
+        // Still reachable over Bluetooth: not lost, as the FFI decides.
+        if self.ble_linked(&peer) {
+            return;
+        }
         self.protocol.on_neighbor_lost(&peer);
         push_event(&self.events, &Event::NeighborLost { peer_id: peer });
     }
@@ -581,6 +611,11 @@ impl Node {
         self.request_nostr_key_packages(peer);
     }
 
+    /// Whether `peer` is announced on a peer-stream link now.
+    pub(crate) fn stream_linked(&self, peer: &str) -> bool {
+        self.peers.contains_key(peer)
+    }
+
     fn has_session(&self, peer: &str) -> bool {
         self.protocol
             .mls_manager()
diff --git a/crates/offline-protocol-web/src/nostr_tests.rs b/crates/offline-protocol-web/src/nostr_tests.rs
index 3e997b6a..2dd3fc7c 100644
--- a/crates/offline-protocol-web/src/nostr_tests.rs
+++ b/crates/offline-protocol-web/src/nostr_tests.rs
@@ -103,6 +103,7 @@ fn peer(profile: &str, username_discovery: bool) -> Peer {
             cold_contact: true,
             username_discovery,
         }),
+        ..Default::default()
     };
     let mut node = Node::with_options("web-nostr-test", profile, options).expect("node");
     let address = node.initialize().expect("initialize");
@@ -392,6 +393,7 @@ fn a_linked_peer_is_not_looked_up_on_the_relays() {
 #[test]
 fn liveness_reported_before_an_address_is_not_kept() {
     let options = NodeOptions {
+        ble: false,
         nostr: Some(NostrOptions::default()),
     };
     let mut node = Node::with_options("web-nostr-test", "early", options).expect("node");
@@ -541,6 +543,7 @@ fn a_record_that_opens_but_does_not_decode_is_not_an_error() {
 
 fn nostr_node(cold_contact: bool, username_discovery: bool, profile: &str) -> Node {
     let options = NodeOptions {
+        ble: false,
         nostr: Some(NostrOptions {
             cold_contact,
             username_discovery,
diff --git a/crates/offline-protocol-web/src/tests.rs b/crates/offline-protocol-web/src/tests.rs
index 3cef74bd..ec9396fc 100644
--- a/crates/offline-protocol-web/src/tests.rs
+++ b/crates/offline-protocol-web/src/tests.rs
@@ -437,6 +437,49 @@ fn the_relay_guard_skips_test_files_by_word() {
     }
 }
 
+/// The Bluetooth carrier names the GATT contract's UUIDs as the Rust
+/// constants do, and its fallback payload is the transport's floor. A page
+/// looks every characteristic up by UUID on a peer built on another
+/// platform, so a drift here makes every device read as one without the
+/// SDK's service. Identity has no Rust constant; the chapter's table is its
+/// home.
+#[test]
+fn the_bluetooth_carrier_names_the_contracts_uuids() {
+    use offline_protocol_transport::constants::{
+        BLE_APP_TAG_CHAR_UUID, BLE_DEVICE_ID_CHAR_UUID, BLE_HELLO_CHAR_UUID, BLE_MAX_FRAGMENT_SIZE,
+        BLE_MESSAGE_CHAR_UUID, BLE_SERVICE_UUID,
+    };
+    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+    let Some(carrier) = read_repo_file(&root.join("bindings/web/src/bluetooth.js")) else {
+        // A published .crate archive has no workspace around it.
+        return;
+    };
+    let spec = read_repo_file(&root.join("docs/spec/ble-framing.md")).expect("the BLE chapter");
+    let identity = "6E400004-B5A3-F393-E0A9-E50E24DCCA9E";
+    assert!(
+        spec.contains(&format!("| Identity | `{identity}` |")),
+        "the BLE chapter's table no longer names Identity as {identity}"
+    );
+    for (name, uuid) in [
+        ("SERVICE", BLE_SERVICE_UUID),
+        ("MESSAGE", BLE_MESSAGE_CHAR_UUID),
+        ("DEVICE_ID", BLE_DEVICE_ID_CHAR_UUID),
+        ("IDENTITY", identity),
+        ("APP_TAG", BLE_APP_TAG_CHAR_UUID),
+        ("HELLO", BLE_HELLO_CHAR_UUID),
+    ] {
+        let declared = format!("const {name} = \"{}\";", uuid.to_ascii_lowercase());
+        assert!(
+            carrier.contains(&declared),
+            "bluetooth.js must declare {declared} (Web Bluetooth takes lowercase UUIDs)"
+        );
+    }
+    assert!(
+        carrier.contains(&format!("const FLOOR_PAYLOAD = {BLE_MAX_FRAGMENT_SIZE};")),
+        "bluetooth.js must fall back to the transport's {BLE_MAX_FRAGMENT_SIZE}-byte floor"
+    );
+}
+
 /// No shipped file names a relay. The relays a node uses are the
 /// application's choice, through its configuration, and a URL in a doc, a
 /// doc comment or an example is a default by another name: it becomes the
diff --git a/docs/bridges/web.md b/docs/bridges/web.md
index b1b38cec..cf77fd01 100644
--- a/docs/bridges/web.md
+++ b/docs/bridges/web.md
@@ -8,7 +8,8 @@ devices on one network over WebRTC, set up with two codes and no server. A
 node the application gives relays also carries the Nostr transport, which
 reaches any device by its address and resolves usernames, mobile ones
 included (W12), and browsers behind one network address find each other in
-a nearby room (W13).
+a nearby room (W13). In Chromium a page can also link to a phone or a board
+over Bluetooth LE, as a central the person points at a device (W14).
 
 Read [the shared contract](README.md) first. This document covers what is
 specific to a page that runs the engine.
@@ -509,6 +510,90 @@ are signed by a key used once, a presence by its own key. The signature is
 checked on every event, after the room key, because it binds `created_at`:
 without it a relay could re-stamp an old offer and replay it fresh.
 
+### W14. Bluetooth is a central the person points at a device
+
+Web Bluetooth lets a Chromium page be a GATT central and nothing else. It
+cannot advertise, so nothing ever discovers a browser, and it serves no
+GATT, so a peripheral can never dial it back. `BluetoothCarrier` links the
+page's node to a phone or a board running the SDK as a peripheral, once its
+person picks the device in the browser's chooser (inside a click, as the
+browser requires) or from a device this origin was granted before. Firefox
+and Safari have no Web Bluetooth, and neither does any browser on iOS.
+
+The engine carries the same `BleTransport` every binding does, so the
+fragment codec is the one the conformance vectors check
+(`crates/offline-protocol-web/src/ble_host.rs`). The page holds the GATT
+connection and moves bytes.
+
+**A peer is announced only under the address its identity proves.** The
+page reads the Device id and Identity values and hands both over;
+`bleVerify` runs the one verifier, compares the derived address with the
+Device id exactly, and remembers it. `bleLinkUp` announces only an address
+verification produced, so a page cannot name a peer the engine did not
+check.
+
+**The contract's order, and the hello before anything else.** Choose the
+instance, read and verify, subscribe to Message (notify or indicate,
+whichever the peripheral offers), write Hello with response, then tell the
+engine the link is up. Nothing is written to Message before the hello: a
+peripheral cannot dial a browser back, so the hello is the only way it can
+attribute a write, and one it cannot attribute is dropped. The iOS, Android
+and Python peripherals serve Hello. With several instances of the service
+behind one device, the carrier binds the one carrying this application's
+App tag, else the only untagged one, else the first, as the chapter says.
+
+**The hello is required, where the chapter makes it optional.** The
+chapter lets a central skip Hello, and keep a link whose hello was
+refused, because the peripheral can still dial that central back and read
+who it is. Nothing can dial a browser. A device that serves no Hello, or
+refuses the write, would be announced as a neighbour that neither receives
+nor sends, so the carrier refuses it and disconnects. A refused write is
+not rare: at an ATT MTU below 99 the browser sends the 96-byte hello as a
+prepared write, which a peripheral must refuse.
+
+**A link is announced only once the whole handshake held.** The carrier
+watches for a disconnect from the moment the GATT connects. A device that
+drops during the handshake is refused and never announced. One that drops
+later is reported lost once, and so is a link the page closes after its
+server already went away, when no disconnect event follows. A link that
+replaces an earlier one to the same address keeps the peer announced: the
+engine neither loses nor rediscovers it. Linking a device that is already
+linked on a live connection hands back its peer and does nothing else: the
+browser returns the same device and the same characteristics for one
+physical device, so a second handshake would subscribe twice and deliver
+every fragment to the engine once per handshake. Every path that ends a
+link removes its notification listener for the same reason. Two connects
+to one device at once share one handshake: the engine holds one
+verification per address, so a second handshake would fail its link-up
+and take the first one's link down with it.
+
+**The write payload is the browser's if it says, and the floor if not.**
+Chromium does not expose the negotiated ATT MTU to a page. The carrier
+reads `maxWriteWithoutResponseSize` on the Message characteristic, where
+the Web Bluetooth proposal puts it, and otherwise uses the 185-byte floor
+every binding falls back to. No browser reports it yet, so the floor is
+what runs today. A link whose ATT MTU is
+below 188 cannot carry a 185-byte fragment, and no shipped device is known
+to negotiate one, but it has not been measured from Chrome on Android
+(see what is not built).
+
+**Writes to a link go one at a time, in order, and a failed write closes
+it.** A fragment lost mid-message leaves the peer reassembling something
+that never completes. A peer linked over a peer stream as well as
+Bluetooth is lost only when the last link goes, and stopping the node
+reports every Bluetooth link lost and closes the carrier's connections.
+
+**One carrier per node.** The node has one outbound Bluetooth sink. A
+second `BluetoothCarrier` on the same node throws until the first is
+released (`release()`); before, it took every outbound fragment and
+dropped those for links it did not hold, and the first carrier's links
+went silent with no error.
+
+**A remembered device can keep `connect` waiting.** `known()` returns
+devices the origin was granted, in range or not, and the browser's
+connect to one out of range may wait on its own timeout. An application
+that reconnects on load should bound the wait itself.
+
 ## Which shared rules apply
 
 | Rule | How it reaches the web binding |
@@ -538,6 +623,8 @@ without it a relay could re-stamp an old offer and replay it fresh.
 - Its own Nostr relays, if the node is to reach devices it has no link to
   (W12), and its own STUN server and an `admit` decision for a nearby room
   (W13). The SDK names no relay and no STUN server.
+- A click that calls `BluetoothCarrier.request()`, for a Bluetooth link
+  (W14): the browser opens its chooser only inside a user gesture.
 - The engine module where the worker can fetch it: beside `worker.js`, or
   handed to `OfflineNode.start({ wasm })` by a host whose worker starts from
   a Blob URL (an editor plugin, an extension), where a relative fetch has
@@ -617,6 +704,22 @@ start there, because Firefox answers it by asking the user.
   redialled and never counts as live, and closing the driver reports it down
   before it ends a query (W12).
 
+- `bluetooth.html`: a node links to SDK peripherals over its Bluetooth
+  carrier, through a stand-in for `navigator.bluetooth`
+  (`test/fake-bluetooth.js`) whose devices are other nodes playing the
+  peripheral, so the engine runs on both sides. The chooser is asked for the
+  SDK's service, the hello is the first write, a session forms and a
+  message of several fragments crosses each way. A peripheral serving a
+  Device id its identity does not prove, one serving no Hello, one refusing
+  the hello and one dropping mid-handshake are each refused, disconnected
+  and never announced. Linking a device already linked hands back its
+  peer and delivers each fragment once, and two connects to one device at
+  once share one handshake. A second carrier is refused until the first is
+  released. Of two instances behind one device the one tagged with this
+  application is bound; a disconnect reports the peer lost, as does closing
+  a link whose server is already gone; stopping the node closes the
+  carrier's links (W14). Moving the hello after the link-up makes the page
+  fail, which is how the page was checked.
 - `nearby.html`: four nodes in nearby rooms, three behind one address and
   one behind another. The address comes from the runner's STUN server, which
   reports a fixed one (`test/stun-server.mjs`); Firefox runs with
@@ -674,6 +777,12 @@ two-player game between devices over WebRTC with durable nodes.
   reliable rung that arrives behind more than 32 newer ephemeral frames is
   retried until it fails, and holds that member out of ephemeral frames
   meanwhile ([ADR 0032](../adr/0032-ephemeral-sends-go-once-over-a-live-link.md)).
+- **Bluetooth from a real browser to a real device.** No engine runs Web
+  Bluetooth without a person at the chooser, so CI drives the carrier
+  through a stand-in. Chrome on Android, macOS and Linux against an
+  Android phone and a board is a manual check, and so is the ATT MTU Chrome
+  negotiates on each (W14). Reaching an iPhone needs its peripheral to serve
+  Hello, which iOS releases before this one do not.
 - **A published package.** `bindings/web` is private on npm and the
   `offline-protocol-web` crate has not been released; build both from
   source.
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
index 4ba5c79b..bbb23b2c 100644
--- a/docs/security/threat-model.md
+++ b/docs/security/threat-model.md
@@ -1343,6 +1343,25 @@ real device off the list between its announcements. The application's own
 policy (who it asks about, how fast it lets questions come) is the limit
 here, not a count in the SDK.
 
+### R31. A Bluetooth chooser names nearby devices, and a granted device stays granted
+
+A page that opens the Web Bluetooth chooser shows its person the name every
+nearby SDK peripheral advertises. The device the person picks is granted to
+the origin, and Chromium remembers the grant: the origin can reconnect to
+that device later without asking (`getDevices`), for as long as the grant
+stands
+([web bridge W14](../bridges/web.md#w14-bluetooth-is-a-central-the-person-points-at-a-device)).
+
+**Why it stands:** the chooser is the browser's, and it is the only way a
+page reaches Bluetooth at all; the grant is what lets a page reconnect
+without a click every time.
+
+**What bounds it:** the chooser is filtered to the SDK's service, and a page
+learns nothing about a device until its person picks it. A link carries
+bytes only: the engine announces a peer under the address its identity
+proves, never the advertised name, and MLS admits it to nothing until it is
+invited. The person can revoke a grant in the browser's site settings.
+
 ## Network egress
 
 Until 0.26 the Rust crates opened no socket: every byte that left a device
diff --git a/docs/spec/ble-framing.md b/docs/spec/ble-framing.md
index b1b7e687..24c1752b 100644
--- a/docs/spec/ble-framing.md
+++ b/docs/spec/ble-framing.md
@@ -202,7 +202,12 @@ only. Among the instances that expose both Device id and Identity, it takes:
 When no instance exposes both, it takes the first, and the handshake refuses it
 for the characteristic it lacks. "First" is the order the platform reports the
 instances in. A central that finds one instance has nothing to choose, and the
-shipped centrals do not read App tag at all in that case.
+shipped centrals do not read App tag at all in that case. The shipped
+centrals include the browser's Web Bluetooth carrier, which can never be
+dialled back: it writes Hello before any Message write, so a peripheral that
+serves Hello can attribute everything it sends. For the same reason it
+refuses an instance with no Hello, and a link whose hello was refused,
+where the rule above lets a central keep them.
 
 The choice never refuses a link. A phone running only other applications is
 still a mesh neighbour: relay forwarding is keyed on the recipient, never on the