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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 11 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,24 +144,17 @@ jobs:
rust_tests:
- *rust
- 'tools/**'
- 'bindings/react-native/**'
- 'bindings/python/**'
- 'bindings/flutter/**'
- 'bindings/c/include/**'
- 'bindings/swift/README.md'
- 'bindings/web/scripts/**'
- 'bindings/web/src/nostr-signal.js'
- 'docs/spec/**'
- 'docs/bridges/README.md'
- 'docs/bridges/c.md'
- 'docs/bridges/flutter.md'
- 'docs/android-integration.md'
- 'docs/api-reference.md'
- 'docs/configuration.md'
- 'docs/ios-integration.md'
- 'docs/mls-integration.md'
- 'CLAUDE.md'
- 'QUICKSTART.md'
# The guards read binding sources (the bridges, the C header,
# the web driver's constants, the build scripts) by path, and
# `no_shipped_file_names_a_relay` reads every shipped doc,
# example, script and binding source for a relay URL, so every
# one of these trees and the top-level docs (CLAUDE.md,
# QUICKSTART.md, CHANGELOG.md) is a guard input.
- 'bindings/**'
- 'docs/**'
- 'examples/**'
- 'scripts/**'
- '*.md'
- '.github/workflows/**'

spec_vectors:
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Added

- **The browser reaches devices it has no link to, over Nostr.**
`OfflineNode.start({ nostr: { relays } })` adds the Nostr transport every
binding shares, driven from the worker over the relays the application
names; the SDK names none and refuses an empty list. A message to a bare
address fetches the key package the device published and forms the
session, so two nodes that share only a relay can talk. It is the
transport and wire format the mobile bindings run; a browser to phone pair
has not been run on devices yet.
`resolveUsername` reads the username directory (`usernameDiscovery`, off
by default, publishes this profile and resolves others'), and
`establishSecureSession`, `createInvite` and `parseInvite` match the
native calls ([W12](docs/bridges/web.md#w12-the-nostr-transport-is-driven-from-the-worker-over-the-applications-relays)).

- **Shared React Native engine bridge.** Engine operations live in
`ProtocolEngineHost` and `ProtocolEngineHostBridge`; `OfflineProtocolModule`
delegates through `ProtocolEngineHostBridgeReactNative` with promise fallback
Expand Down Expand Up @@ -152,6 +165,15 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Changed

- **No shipped file names a Nostr relay.** The docs, doc comments and the
Nostr example listed public relays; they now show a placeholder, and the
example starts with an empty list for the developer to fill.
`no_shipped_file_names_a_relay` keeps it so: a relay the SDK names becomes
the relay every application uses.
- **The Nostr driver guide no longer completes a resolution on the first
EOSE.** `docs/nostr.md` said so while the bridges and the username chapter
wait for every relay; the guide now says what the code does.

- **The workspace reads the clock through `offline_protocol_core::time`.** It
is `std::time` on every target but the browser, where `std::time` compiles
and panics on first use; there it is `web-time`. Nothing changes on any
Expand Down
2 changes: 1 addition & 1 deletion bindings/react-native/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ export interface ReticulumTransportConfig {
export interface NostrTransportConfig {
/** Enable Nostr transport (default: false) */
enabled: boolean;
/** List of Nostr relay WebSocket URLs (e.g., ["wss://relay.damus.io"]) */
/** Nostr relay WebSocket URLs (`wss://`) the application chooses. The SDK names none. */
relayUrls?: string[];
/** Connection timeout in seconds (default: 30) */
connectionTimeout?: number;
Expand Down
54 changes: 53 additions & 1 deletion bindings/web/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface StartOptions {
storage?: "durable" | "memory";
/** Link to this origin's other tabs over a BroadcastChannel. Default true. */
tabs?: boolean;
/** The Nostr transport over relays the application names (W12). */
nostr?: NostrOptions;
/** Where worker.js is served, if not beside this module. */
workerUrl?: string | URL;
/**
Expand All @@ -26,6 +28,40 @@ export interface StartOptions {
wasm?: BufferSource | WebAssembly.Module;
}

export interface NostrOptions {
/**
* The relays to use, as ws:// or wss:// URLs. Required: the SDK names no
* relay of its own.
*/
relays: string[];
/**
* Publish key packages so a device that knows only this node's address
* can reach it, and fetch others' (cold first contact). Default true.
*/
coldContact?: boolean;
/**
* Publish `profile` as a username in the relays' directory, and resolve
* others'. Default false: it binds a readable name to this address in
* public. Needs `coldContact`.
*/
usernameDiscovery?: boolean;
}

/** Where `establishSecureSession` left a session. */
export type SessionStart = "created" | "existing" | "pending";

/** An invite another device shared, verified. */
export interface InviteInfo {
/** The address, checked to derive from `publicKey`. */
address: string;
/** The Ed25519 identity key, base64. */
publicKey: string;
/** A suggested name; the person may change it. */
petname: string | null;
/** Whether a signature binds the petname to the key. */
signed: boolean;
}

/** A group as this device holds it. */
export interface GroupInfo {
group_id: string;
Expand Down Expand Up @@ -66,7 +102,8 @@ export interface PageCarrier {
* as a DOM event named by its `type` and as `event`, with the JSON as `detail`.
*
* Two more events are the binding's own. `error` carries a tick that failed
* and the node carried on. `fatal` carries why the node ended (its module
* and the node carried on, or a Nostr relay the node cannot use (one the
* browser will not dial, or one that closed this device's subscription). `fatal` carries why the node ended (its module
* trapped, its worker failed, or it was terminated): every call in flight and
* every later one rejects. Starting the node again with the same `appId` and
* `profile` is the same device when its storage is durable.
Expand All @@ -81,6 +118,21 @@ export class OfflineNode extends EventTarget {
* or no session.
*/
sendMessage(to: string, content: string, options?: SendOptions): Promise<string>;
/**
* Starts a session with a device. "pending" means its key package has not
* arrived; with relays, the node has asked them for it, and
* `secure_session_established` follows.
*/
establishSecureSession(address: string): Promise<SessionStart>;
/**
* Starts a directory lookup; the answer is one `username_resolved` event
* with every claim. Needs `nostr.usernameDiscovery`.
*/
resolveUsername(username: string): Promise<boolean>;
/** An invite naming this device, base64url. */
createInvite(options?: { petname?: string; sign?: boolean }): Promise<string>;
/** Opens and verifies an invite; rejects one that does not verify. */
parseInvite(blob: string): Promise<InviteInfo>;
createGroup(name: string): Promise<{ group_id: string; [field: string]: unknown }>;
inviteToGroup(groupId: string, member: string): Promise<void>;
/**
Expand Down
57 changes: 56 additions & 1 deletion bindings/web/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export { WebRtcCarrier } from "./webrtc.js";
export { NostrSignaller } from "./nostr-signal.js";
import { checkRelays } from "./nostr-relays.js";

export class OfflineNode extends EventTarget {
#worker;
Expand Down Expand Up @@ -43,15 +44,23 @@ export class OfflineNode extends EventTarget {
/// worker, in sealed origin-private files) or "memory" (a new device every
/// start). `tabs: false` leaves out the carrier between this origin's tabs,
/// for a node that links only through carriers the page attaches.
///
/// `nostr: { relays, coldContact, usernameDiscovery }` adds the Nostr
/// transport over the relays the application names (W12). The SDK names
/// 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.
static async start({
appId,
profile = "default",
storage = "durable",
tabs = true,
nostr,
workerUrl,
wasm,
} = {}) {
if (!appId) throw new Error("appId is required");
if (nostr !== undefined) checkRelays(nostr?.relays);
if (storage === "durable") {
// Best effort: without it the browser may evict the origin's files
// under storage pressure, identity included. Never awaited: Firefox
Expand All @@ -64,7 +73,23 @@ export class OfflineNode extends EventTarget {
const instance = crypto.randomUUID();
const node = new OfflineNode(new Worker(url, { type: "module" }), { appId, instance, tabs });
try {
node.address = await node.#call("start", { appId, profile, storage, tabs, instance, wasm });
node.address = await node.#call("start", {
appId,
profile,
storage,
tabs,
instance,
wasm,
// The worker applies the defaults; it is the one place that does.
nostr:
nostr === undefined
? undefined
: {
relays: [...nostr.relays],
coldContact: nostr.coldContact,
usernameDiscovery: nostr.usernameDiscovery,
},
});
} catch (error) {
// The worker kept nothing (no engine, lock or handle), but it is still
// a running worker with a module in it.
Expand Down Expand Up @@ -108,6 +133,36 @@ export class OfflineNode extends EventTarget {
return this.#call("sendGroupMessage", { groupId, content, ephemeral });
}

/// Starts a session with `address`: "created" (the Welcome went out now),
/// "existing", or "pending" while its key package has not arrived. On a
/// node with relays, "pending" has asked them for the packages the device
/// published, and `secure_session_established` follows. A message sent
/// meanwhile waits for the session, so calling this first is optional.
establishSecureSession(address) {
return this.#call("establishSecureSession", { address });
}

/// Looks a username up in the directory the relays hold (W12). Resolves
/// once the lookup is under way; the answer is one `username_resolved`
/// event carrying every device that claims the name. A name is a claim,
/// not an identity: show the person the whole set, and keep the address
/// they choose.
resolveUsername(username) {
return this.#call("resolveUsername", { username });
}

/// An invite naming this device (base64url): its address and key, and an
/// optional petname that `sign: true` binds to the key.
createInvite({ petname, sign = false } = {}) {
return this.#call("createInvite", { petname, sign });
}

/// Opens an invite: `{ address, publicKey, petname, signed }`, the address
/// checked to derive from the key. Rejects an invite that does not verify.
parseInvite(blob) {
return this.#call("parseInvite", { blob });
}

/// A fresh room code for NostrSignaller: eighty bits a person can read out.
newRoom() {
return this.#call("signalNewRoom");
Expand Down
Loading
Loading