@cef-ai/widget-runtime 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,15 +16,175 @@ for consumers that import the helpers directly.
16
16
 
17
17
  The runtime resolves a signing identity from the best available source:
18
18
 
19
- 1. **Embedded (host):** when iframed by the Cere Vault, it signs through the
20
- host's postMessage sign-bridge.
21
- 2. **Standalone (interactive wallet):** when there is no responding host — the
22
- widget is opened top-level via a direct link — it connects an interactive
23
- Cere embed-wallet built from `manifest.wallet.{appId,env}` (opens the wallet
24
- UI) and signs locally.
25
-
26
- Both paths feed the same `@cef-ai/vault-sdk` client, so cubby reads, connect,
27
- publish, and audio uploads behave identically embedded or standalone.
19
+ 1. **Embedded (host):** when the widget is framed, it asks the embedding page
20
+ for an identity over `postMessage` and signs through it. The host does not
21
+ have to be the Cere Vault — the contract is host-agnostic and exported (see
22
+ [Host contract](#host-contract) below), so a browser-extension panel, a
23
+ partner app, or any page that has already authenticated the user can supply
24
+ it.
25
+ 2. **Standalone (interactive wallet) — currently NON-FUNCTIONAL.** When the
26
+ widget is **not** framed — opened top-level via a direct link — it was meant
27
+ to connect an interactive Cere embed-wallet built from
28
+ `manifest.wallet.{appId,env}` and sign locally. That path terminates at
29
+ `beta.openlogin.com`, which no longer resolves, so it fails rather than
30
+ opening a wallet. Tracked in CEF-AI/sdk#144 (dead endpoint) and #146
31
+ (replacement); `standalone-wallet.ts` is unchanged and still shipped.
32
+ **Until those land, treat path 1 as the only working one: frame the widget
33
+ and mount a host.**
34
+
35
+ Path 1 feeds the `@cef-ai/vault-sdk` client, so cubby reads, connect, publish,
36
+ and audio uploads work the same way whichever host supplies the identity.
37
+
38
+ **A framed widget never falls back to (2)** (behaviour change in 1.1). If the
39
+ host does not answer the handshake within 8 s, or answers malformed, the runtime
40
+ surfaces `WidgetSignedOutError` and the identity stays `anon`. It used to
41
+ silently open the standalone wallet instead, which looked like a hang and
42
+ produced an identity the host knew nothing about. The Cere Vault host already
43
+ implements the handshake, so it is unaffected; a page that frames a CEF widget
44
+ **without** implementing the host contract now gets `WidgetSignedOutError` where
45
+ it previously got the silent fallback. If you frame a widget in your own page,
46
+ mount [`createWidgetHost`](#host-contract) on it.
47
+
48
+ ## Host contract
49
+
50
+ Four `postMessage` messages, widget → host and back.
51
+
52
+ **Gate the inbound side first.** The host holds the user's signing key, so
53
+ `createWidgetHost` makes you name who may talk to it. `allowedOrigins` is
54
+ **required and non-empty**, and it throws at mount without it — an ungated
55
+ listener is a signing oracle for every other frame, opener, or popup on the
56
+ page.
57
+
58
+ **A frame handle does not pin a document.** `widget` is an optional, and
59
+ recommended, *extra* narrowing — never a replacement for `allowedOrigins`. A
60
+ `WindowProxy` keeps its identity across navigation, **cross-origin included**:
61
+ after the framed page follows an open redirect, or navigates itself, or has its
62
+ `src` set from a query param, `event.source === iframe.contentWindow` still
63
+ holds and only `event.origin` has moved. A host gated on the frame alone would
64
+ sign arbitrary bytes for whatever later occupies that slot. When both options
65
+ are given, **both must pass**.
66
+
67
+ Each `allowedOrigins` entry must be a canonical origin spelled exactly as
68
+ `event.origin` reports it — scheme + host + non-default port, lower-case, no
69
+ trailing slash, no path (`'https://widget.example'`,
70
+ `'http://localhost:5173'`). A default port is not optional spelling: an origin
71
+ omits `:443` on https and `:80` on http, so `'https://widget.example:443'`
72
+ throws too. Anything else throws at mount rather than failing
73
+ silently at runtime. In particular **`'*'` is rejected**: `postMessage` takes
74
+ `*` as a `targetOrigin` wildcard, but `event.origin` is never the string `"*"`,
75
+ so an allowlist of `['*']` would mount clean and then answer nobody.
76
+
77
+ **Sandboxed widgets.** A sandboxed frame's `event.origin` is the literal string
78
+ `"null"`, so pass `allowedOrigins: ['null']` to frame one. That is an opaque
79
+ origin — every sandboxed frame reports it, so pair it with `widget` if any
80
+ other sandboxed frame can reach the page. Separately, a `"null"` origin relaxes
81
+ only the **reply** `targetOrigin` (`'*'`, since `"null"` is not a valid
82
+ `targetOrigin`); it never relaxes the inbound decision.
83
+
84
+ Bytes on the wire are `number[]`, **not** `Uint8Array`. Structured clone handles
85
+ typed arrays everywhere; the reason is the rest of the chain. A real host
86
+ usually forwards the payload over a JSON-serialising transport —
87
+ `chrome.runtime.sendMessage` to an extension service worker, a native-messaging
88
+ port — which turns a `Uint8Array` into `{"0":1,"1":2,…}` and hands the signer
89
+ something the widget never sent.
90
+
91
+ | Message | Direction | Fields |
92
+ | --- | --- | --- |
93
+ | `cef-widget:identity-request` | widget → host | `requestId: string`, `v: number` |
94
+ | `cef-widget:identity-response` | host → widget | `requestId: string`, `v?: number`, `pubkey?: string`, `sigType?: 'ed25519' \| 'sr25519'` (default `ed25519`), `error?: string`, `errorCode?: 'unsupported-version'` |
95
+ | `cef-widget:sign-request` | widget → host | `requestId: string`, `v: number`, `bytes: number[]` |
96
+ | `cef-widget:sign-response` | host → widget | `requestId: string`, `v?: number`, `signature: number[] \| null` (`null` = user declined), `error?: string`, `errorCode?: 'unsupported-version'` |
97
+
98
+ Rules:
99
+
100
+ - **Versioning (`v`).** Current version is `1` (`PROTOCOL_VERSION`). A message
101
+ that carries **no `v` is treated as v1** — that is what keeps
102
+ already-deployed widgets and the existing Vault host working untouched. An
103
+ **unrecognised `v` is rejected** with a named `WidgetProtocolVersionError`;
104
+ it is never silently ignored. A peer that rejects the other side's `v`
105
+ answers with `errorCode: 'unsupported-version'` — its own reply is still a v1
106
+ message, so `v` itself cannot carry the rejection, and without the code the
107
+ receiver would just see an opaque error string and keep retrying.
108
+ - **Sign verbatim.** The host must sign the exact bytes it receives with
109
+ **`ed25519_signRaw`**. Do **not** use the high-level `signMessage` /
110
+ `wallet_signMessage`, which wrap the payload in Substrate's
111
+ `<Bytes>…</Bytes>` envelope — a signature over the wrapped form verifies
112
+ nowhere (not in GAR, not at the DDC gateway).
113
+ - **Validate what you sign.** Every element of `bytes` must be an integer
114
+ `0–255`, and the payload is capped at 64 KiB (`MAX_SIGN_BYTES`). `Uint8Array.from`
115
+ coerces junk silently (`300` → `44`, `'zz'` → `0`, and a sparse array's holes
116
+ → `0`), which would produce a perfectly valid signature over the wrong
117
+ payload. `createWidgetHost` does this check for you and answers
118
+ `WidgetMalformedMessageError`; a hand-rolled host must do it itself —
119
+ `isByteArray` and `MAX_SIGN_BYTES` are exported for exactly that, so the cap
120
+ moves in one place. The widget applies the same check to `signature`, and
121
+ refuses to post a payload over the cap in the first place.
122
+ - **Timing.** The widget retries `identity-request` every **400 ms** for up to
123
+ **8 s** before giving up (the host page and the iframe race to attach their
124
+ listeners, so the first request is often sent before the host is ready — just
125
+ answer whichever request you see). A reply **slower than the retry interval
126
+ still counts**: every request the handshake issued stays open, which is what a
127
+ cold extension service worker needs. A `sign-request` waits up to **60 s**,
128
+ the user's time to approve in the host's UI. A handshake that times out is not
129
+ cached — a later action (the user connects after the widget loaded) starts a
130
+ fresh one.
131
+ - **Replies are source-gated.** The widget only accepts messages whose
132
+ `event.source === window.parent`. Always reply to `event.source`, echoing the
133
+ request's `requestId`.
134
+
135
+ ### Mounting a host (non-Vault example)
136
+
137
+ `createWidgetHost` is the host side of the table above — one listener, no
138
+ dependencies. Mount it once on the page that frames the widget:
139
+
140
+ ```ts
141
+ import { createWidgetHost } from '@cef-ai/widget-runtime';
142
+
143
+ // An extension panel that has ALREADY authenticated the user with SCP Wallet.
144
+ const dispose = createWidgetHost({
145
+ // REQUIRED. The gate between the page and the user's key: which DOCUMENT may
146
+ // ask. Canonical origins only — no trailing slash, no path, and no '*'.
147
+ // Use ['null'] for a sandboxed widget.
148
+ allowedOrigins: ['https://widget.example'],
149
+
150
+ // Optional but recommended: narrow to one frame as well. Both must pass.
151
+ // On its own this would NOT be safe — the handle survives navigation.
152
+ widget: document.querySelector<HTMLIFrameElement>('iframe#cef-widget')!,
153
+
154
+ // Return null/undefined while the user is not signed in yet — the widget
155
+ // retries for 8 s, and a later action retries again after that.
156
+ getIdentity: () => (scp.isConnected() ? { pubkey: scp.publicKey, sigType: 'ed25519' } : null),
157
+
158
+ // Verbatim raw-bytes signing. Return null if the user declines; throw to
159
+ // report a failure. Never route this to signMessage/wallet_signMessage.
160
+ //
161
+ // NOTE: called more than once per widget action — a first `query()` signs
162
+ // twice (delegation-token mint, then vault claim). If you put a user prompt
163
+ // behind this, the reader sees two prompts before the first cubby read.
164
+ // Cache or batch consent rather than prompting per call.
165
+ sign: async (bytes) => scp.request({ method: 'ed25519_signRaw', params: [bytes] }),
166
+ });
167
+
168
+ // On unmount:
169
+ dispose();
170
+ ```
171
+
172
+ The widget then resolves its identity through the host, exactly as it does
173
+ inside the Cere Vault, and the standalone wallet is never opened.
174
+
175
+ **Chrome extension hosts:** an MV3 panel must also allow the widget's origin in
176
+ its own CSP, or the frame never loads and the handshake has nothing to answer:
177
+
178
+ ```json
179
+ "content_security_policy": {
180
+ "extension_pages": "script-src 'self'; object-src 'self'; frame-src https://widget.example"
181
+ }
182
+ ```
183
+
184
+ Verified end-to-end against a real extension: the widget resolves the host's
185
+ identity, signatures cross the frame as `number[]` and verify, and with no host
186
+ mounted the framed widget fails `WidgetSignedOutError` without reaching the
187
+ legacy wallet.
28
188
 
29
189
  ## Build
30
190
 
package/dist/entry.js CHANGED
@@ -3,6 +3,7 @@ import { VaultSDK } from '@cef-ai/vault-sdk';
3
3
  import { createHostBridge } from './host-bridge.js';
4
4
  import { toVaultSdkSigner } from './bridge-wallet.js';
5
5
  import { connectStandaloneWallet } from './standalone-wallet.js';
6
+ import { createIdentityResolver } from './resolve-identity.js';
6
7
  import { createVaultClient } from './vault-client.js';
7
8
  import { createWidgetRuntime } from './runtime.js';
8
9
  import { captureAndUploadAudio, createVaultObjectUploader } from './audio/capture-upload.js';
@@ -27,42 +28,16 @@ function boot() {
27
28
  return;
28
29
  const bridge = createHostBridge();
29
30
  // ── Multi-source identity ─────────────────────────────────────────────────
30
- // Tier 1: the Vault host iframes us → use the postMessage sign-bridge.
31
- // Tier 2/3: no responding host (top-level link, or the bridge times out
32
- // returning null) → connect an interactive Cere embed-wallet built from
33
- // `manifest.wallet.{appId,env}` (opens the wallet UI). Both paths yield a
34
- // pubkey + raw-bytes signer that feed the SAME vault-sdk client.
35
- //
36
- // Memoized so the (possibly interactive) resolution runs at most once — the
37
- // wallet popup must not open on every query/connect/upload.
38
- let identityP = null;
39
- const resolveSigningIdentity = () => {
40
- if (identityP)
41
- return identityP;
42
- identityP = (async () => {
43
- const hostId = await bridge.requestIdentity();
44
- if (hostId) {
45
- return {
46
- source: 'host',
47
- publicKey: hostId.pubkey,
48
- sigType: hostId.sigType,
49
- sign: (bytes) => bridge.sign(bytes),
50
- };
51
- }
52
- // No host → interactive wallet (standalone / direct-link boot).
53
- const wallet = await connectStandaloneWallet(manifest.wallet);
54
- return {
55
- source: 'wallet',
56
- publicKey: wallet.pubkey(),
57
- sigType: wallet.sigType,
58
- sign: (bytes) => wallet.signRawBytes(bytes),
59
- };
60
- })().catch((err) => {
61
- identityP = null; // let a later action retry (e.g. user reopens the wallet)
62
- throw err;
63
- });
64
- return identityP;
65
- };
31
+ // Framed → the embedding host (Cere Vault, or any page mounting
32
+ // `createWidgetHost`) answers the postMessage handshake. Top-level → an
33
+ // interactive Cere embed-wallet built from `manifest.wallet.{appId,env}`.
34
+ // Both paths yield a pubkey + raw-bytes signer feeding the SAME vault-sdk
35
+ // client. A framed widget whose handshake fails throws WidgetSignedOutError
36
+ // rather than downgrading to the wallet — see ./resolve-identity.
37
+ const resolveSigningIdentity = createIdentityResolver({
38
+ bridge,
39
+ connectWallet: () => connectStandaloneWallet(manifest.wallet),
40
+ });
66
41
  const deps = {
67
42
  resolveIdentity: async () => {
68
43
  const id = await resolveSigningIdentity();
@@ -1,11 +1,81 @@
1
+ import type { VaultSigType } from '@cef-ai/vault-sdk';
1
2
  export declare const IDENTITY_REQUEST_TYPE = "cef-widget:identity-request";
2
3
  export declare const IDENTITY_RESPONSE_TYPE = "cef-widget:identity-response";
3
4
  export declare const SIGN_REQUEST_TYPE = "cef-widget:sign-request";
4
5
  export declare const SIGN_RESPONSE_TYPE = "cef-widget:sign-response";
6
+ /**
7
+ * Protocol version stamped on every outgoing message as `v`.
8
+ *
9
+ * Compatibility rule: a message that carries NO `v` is treated as v1. That is
10
+ * what keeps every already-deployed widget and the existing Vault host working
11
+ * untouched — neither of them sends `v`. An unrecognised `v` is a hard failure
12
+ * (`WidgetProtocolVersionError`), never a silently ignored message.
13
+ */
14
+ export declare const PROTOCOL_VERSION = 1;
15
+ /**
16
+ * `errorCode` value a peer sets when it rejected the OTHER side's `v`. The
17
+ * reply itself is still a v1 message, so `v` cannot carry this: without the
18
+ * code the receiver would see a plain error string and keep retrying.
19
+ */
20
+ export declare const UNSUPPORTED_VERSION_CODE = "unsupported-version";
21
+ /** Largest `bytes` payload a host will sign, and largest signature accepted. */
22
+ export declare const MAX_SIGN_BYTES: number;
23
+ export type SigType = VaultSigType;
24
+ export interface IdentityRequest {
25
+ type: typeof IDENTITY_REQUEST_TYPE;
26
+ requestId: string;
27
+ v: number;
28
+ }
29
+ export interface IdentityResponse {
30
+ type: typeof IDENTITY_RESPONSE_TYPE;
31
+ requestId: string;
32
+ /** Omitted by pre-versioning hosts; absent means v1. */
33
+ v?: number;
34
+ pubkey?: string;
35
+ /** Defaults to 'ed25519' when omitted. */
36
+ sigType?: SigType;
37
+ error?: string;
38
+ /** Machine-readable reason; see UNSUPPORTED_VERSION_CODE. */
39
+ errorCode?: typeof UNSUPPORTED_VERSION_CODE;
40
+ }
41
+ export interface SignRequest {
42
+ type: typeof SIGN_REQUEST_TYPE;
43
+ requestId: string;
44
+ v: number;
45
+ /** Bytes to sign verbatim. number[], not Uint8Array — see the README. */
46
+ bytes: number[];
47
+ }
48
+ export interface SignResponse {
49
+ type: typeof SIGN_RESPONSE_TYPE;
50
+ requestId: string;
51
+ /** Omitted by pre-versioning hosts; absent means v1. */
52
+ v?: number;
53
+ /** number[] signature, or null when the user declined. */
54
+ signature: number[] | null;
55
+ error?: string;
56
+ /** Machine-readable reason; see UNSUPPORTED_VERSION_CODE. */
57
+ errorCode?: typeof UNSUPPORTED_VERSION_CODE;
58
+ }
59
+ /** A peer spoke a protocol version this build does not understand. */
60
+ export declare class WidgetProtocolVersionError extends Error {
61
+ constructor(detail: string);
62
+ }
63
+ /** A peer sent a message that does not match the wire contract. */
64
+ export declare class WidgetMalformedMessageError extends Error {
65
+ constructor(detail: string);
66
+ }
67
+ /** True when `v` is present and is not a version we speak. Absent `v` == v1. */
68
+ export declare function isUnsupportedVersion(v: unknown): boolean;
69
+ /**
70
+ * Wire bytes are number[] — and nothing else. `Uint8Array.from` coerces junk
71
+ * silently (`'zz'` → 0, `300` → 44), which would hand a signer a payload the
72
+ * peer never sent, so both directions validate every element here.
73
+ */
74
+ export declare function isByteArray(value: unknown): value is number[];
5
75
  export interface HostBridge {
6
76
  requestIdentity: () => Promise<{
7
77
  pubkey: string;
8
- sigType: 'ed25519' | 'sr25519';
78
+ sigType: SigType;
9
79
  } | null>;
10
80
  sign: (bytes: Uint8Array) => Promise<Uint8Array>;
11
81
  }
@@ -1,16 +1,73 @@
1
1
  // packages/widget-runtime/src/host-bridge.ts
2
2
  //
3
- // postMessage client that talks to the Vault host's widget-sign-bridge.
4
- // Protocol types MUST match the host repo exactly (protocol.ts in vault):
3
+ // postMessage client for the widget→host identity/signing contract. The Cere
4
+ // Vault is one host; ANY page that frames a widget can implement the same four
5
+ // messages (see `createWidgetHost` in ./widget-host for a ready-made host
6
+ // side, and the protocol table in the package README):
5
7
  // cef-widget:identity-request / cef-widget:identity-response
6
8
  // cef-widget:sign-request / cef-widget:sign-response
7
9
  //
8
- // Wire bytes are always number[] (not Uint8Array) — postMessage does not
9
- // structured-clone Uint8Array reliably across sandboxed origin pairs.
10
+ // The contract rules a host must honour (verbatim signing, number[] on the
11
+ // wire, versioning) live in the README's "Host contract" section — one copy.
10
12
  export const IDENTITY_REQUEST_TYPE = 'cef-widget:identity-request';
11
13
  export const IDENTITY_RESPONSE_TYPE = 'cef-widget:identity-response';
12
14
  export const SIGN_REQUEST_TYPE = 'cef-widget:sign-request';
13
15
  export const SIGN_RESPONSE_TYPE = 'cef-widget:sign-response';
16
+ /**
17
+ * Protocol version stamped on every outgoing message as `v`.
18
+ *
19
+ * Compatibility rule: a message that carries NO `v` is treated as v1. That is
20
+ * what keeps every already-deployed widget and the existing Vault host working
21
+ * untouched — neither of them sends `v`. An unrecognised `v` is a hard failure
22
+ * (`WidgetProtocolVersionError`), never a silently ignored message.
23
+ */
24
+ export const PROTOCOL_VERSION = 1;
25
+ /**
26
+ * `errorCode` value a peer sets when it rejected the OTHER side's `v`. The
27
+ * reply itself is still a v1 message, so `v` cannot carry this: without the
28
+ * code the receiver would see a plain error string and keep retrying.
29
+ */
30
+ export const UNSUPPORTED_VERSION_CODE = 'unsupported-version';
31
+ /** Largest `bytes` payload a host will sign, and largest signature accepted. */
32
+ export const MAX_SIGN_BYTES = 64 * 1024;
33
+ /** A peer spoke a protocol version this build does not understand. */
34
+ export class WidgetProtocolVersionError extends Error {
35
+ constructor(detail) {
36
+ super(`WidgetProtocolVersionError: ${detail}`);
37
+ this.name = 'WidgetProtocolVersionError';
38
+ }
39
+ }
40
+ /** A peer sent a message that does not match the wire contract. */
41
+ export class WidgetMalformedMessageError extends Error {
42
+ constructor(detail) {
43
+ super(`WidgetMalformedMessageError: ${detail}`);
44
+ this.name = 'WidgetMalformedMessageError';
45
+ }
46
+ }
47
+ /** True when `v` is present and is not a version we speak. Absent `v` == v1. */
48
+ export function isUnsupportedVersion(v) {
49
+ return v !== undefined && v !== PROTOCOL_VERSION;
50
+ }
51
+ /**
52
+ * Wire bytes are number[] — and nothing else. `Uint8Array.from` coerces junk
53
+ * silently (`'zz'` → 0, `300` → 44), which would hand a signer a payload the
54
+ * peer never sent, so both directions validate every element here.
55
+ */
56
+ export function isByteArray(value) {
57
+ if (!Array.isArray(value) || value.length > MAX_SIGN_BYTES)
58
+ return false;
59
+ for (let i = 0; i < value.length; i++) {
60
+ // Not `.every()`: it SKIPS holes, so `[1, 2, , 4]` passed and
61
+ // `Uint8Array.from` then filled the gap with 0. structuredClone preserves
62
+ // holes, so a sparse array really does survive postMessage.
63
+ if (!(i in value))
64
+ return false;
65
+ const n = value[i];
66
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 255)
67
+ return false;
68
+ }
69
+ return true;
70
+ }
14
71
  /** Singleton message listener + pending-request map; set up once on first use. */
15
72
  let listenerAttached = false;
16
73
  const pending = new Map();
@@ -41,16 +98,21 @@ function ensureListener() {
41
98
  function generateId() {
42
99
  return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
43
100
  }
44
- /** Identity result cached after first successful handshake. */
101
+ /** Identity result cached after a SUCCESSFUL handshake. Failures are not
102
+ * cached: the user typically connects in the host after the widget loaded, so
103
+ * a later action must be able to handshake again. */
45
104
  let cachedIdentity = undefined;
46
105
  /**
47
- * Request the connected-wallet identity from the Vault host.
106
+ * Request the connected-wallet identity from the embedding host.
48
107
  *
49
108
  * Returns null when:
50
109
  * - Not running inside an iframe (window.parent === window).
51
110
  * - Host doesn't reply within ~8 s (attach-vs-load race exhausted).
52
111
  * - Host replies with an error on every attempt.
53
112
  *
113
+ * Rejects with WidgetProtocolVersionError when the host answers with a `v`
114
+ * this build does not speak, or rejects ours.
115
+ *
54
116
  * Resolves immediately (from cache) on subsequent calls after a success.
55
117
  */
56
118
  async function requestIdentity() {
@@ -64,13 +126,17 @@ async function requestIdentity() {
64
126
  const RETRY_INTERVAL_MS = 400;
65
127
  const TOTAL_TIMEOUT_MS = 8000;
66
128
  const deadline = Date.now() + TOTAL_TIMEOUT_MS;
67
- return new Promise((outerResolve) => {
129
+ return new Promise((outerResolve, outerReject) => {
68
130
  let settled = false;
69
131
  let retryTimer;
70
132
  let guardTimer;
71
- let currentRequestId;
72
- /** Clear both the retry timer and the outer deadline guard. */
73
- function clearTimers() {
133
+ // EVERY request this handshake has issued stays pending: a host slower
134
+ // than the 400 ms retry interval (a cold extension service worker easily
135
+ // is) answers an earlier requestId, and that reply must still count.
136
+ const issued = new Set();
137
+ /** Stop the clock and drop every request this handshake issued. */
138
+ function finish() {
139
+ settled = true;
74
140
  if (retryTimer !== undefined) {
75
141
  clearTimeout(retryTimer);
76
142
  retryTimer = undefined;
@@ -79,28 +145,38 @@ async function requestIdentity() {
79
145
  clearTimeout(guardTimer);
80
146
  guardTimer = undefined;
81
147
  }
148
+ for (const id of issued)
149
+ pending.delete(id);
150
+ issued.clear();
82
151
  }
83
152
  function attempt() {
84
153
  if (settled)
85
154
  return;
86
- // Remove old pending entry (the host reply arrives on a per-requestId
87
- // basis; stale IDs just get GC'd from the map when we replace them).
88
- if (currentRequestId)
89
- pending.delete(currentRequestId);
90
- currentRequestId = generateId();
91
- pending.set(currentRequestId, {
155
+ const requestId = generateId();
156
+ issued.add(requestId);
157
+ pending.set(requestId, {
92
158
  resolve: (data) => {
93
159
  if (settled)
94
160
  return;
95
161
  const d = data;
162
+ // A version mismatch is permanent — retrying cannot fix it.
163
+ if (isUnsupportedVersion(d.v)) {
164
+ finish();
165
+ outerReject(new WidgetProtocolVersionError(`the host replied in protocol v${String(d.v)}; this build speaks v${PROTOCOL_VERSION}`));
166
+ return;
167
+ }
168
+ if (d.errorCode === UNSUPPORTED_VERSION_CODE) {
169
+ finish();
170
+ outerReject(new WidgetProtocolVersionError(`the host rejected protocol v${PROTOCOL_VERSION} (${d.error ?? 'no detail'})`));
171
+ return;
172
+ }
96
173
  if (d.error || !d.pubkey) {
97
174
  // Error response — keep retrying until deadline.
98
175
  scheduleRetryOrTimeout();
99
176
  return;
100
177
  }
101
- settled = true;
102
- clearTimers();
103
- const result = { pubkey: d.pubkey, sigType: (d.sigType ?? 'ed25519') };
178
+ const result = { pubkey: d.pubkey, sigType: d.sigType ?? 'ed25519' };
179
+ finish();
104
180
  cachedIdentity = result;
105
181
  outerResolve(result);
106
182
  },
@@ -112,7 +188,7 @@ async function requestIdentity() {
112
188
  });
113
189
  // targetOrigin '*' — same trust assumption as the sign request (see the
114
190
  // note below): host origin unknown to the widget; replies are source-gated.
115
- window.parent.postMessage({ type: IDENTITY_REQUEST_TYPE, requestId: currentRequestId }, '*');
191
+ window.parent.postMessage({ type: IDENTITY_REQUEST_TYPE, requestId, v: PROTOCOL_VERSION }, '*');
116
192
  // Schedule an automatic retry in case no reply arrives within the interval.
117
193
  scheduleRetryOrTimeout();
118
194
  }
@@ -126,11 +202,7 @@ async function requestIdentity() {
126
202
  }
127
203
  const remaining = deadline - Date.now();
128
204
  if (remaining <= 0) {
129
- settled = true;
130
- clearTimers();
131
- if (currentRequestId)
132
- pending.delete(currentRequestId);
133
- cachedIdentity = null;
205
+ finish();
134
206
  outerResolve(null);
135
207
  return;
136
208
  }
@@ -143,45 +215,59 @@ async function requestIdentity() {
143
215
  // Also set an outer deadline guard in case no reply ever arrives.
144
216
  guardTimer = setTimeout(() => {
145
217
  if (!settled) {
146
- settled = true;
147
- clearTimers();
148
- if (currentRequestId)
149
- pending.delete(currentRequestId);
150
- cachedIdentity = null;
218
+ finish();
151
219
  outerResolve(null);
152
220
  }
153
221
  }, TOTAL_TIMEOUT_MS + RETRY_INTERVAL_MS);
154
222
  });
155
223
  }
156
224
  /**
157
- * Ask the Vault host to sign `bytes` with the connected wallet.
225
+ * Ask the embedding host to sign `bytes` verbatim with the connected wallet.
158
226
  *
159
227
  * Rejects on:
228
+ * - A payload over `MAX_SIGN_BYTES` (`WidgetMalformedMessageError`, before
229
+ * anything is posted — the host would only reject it a round trip later,
230
+ * and as an opaque `error` string).
160
231
  * - `signature === null` (user declined).
161
232
  * - `error` string in the response.
162
- * - No response within ~60 s (modal closed / stale-reply race).
233
+ * - A malformed reply (`WidgetMalformedMessageError`).
234
+ * - No response within ~60 s (host closed the widget / stale-reply race).
163
235
  */
164
236
  async function sign(bytes) {
165
237
  if (window.parent === window)
166
238
  throw new Error('sign called outside of an iframe');
239
+ if (bytes.length > MAX_SIGN_BYTES) {
240
+ throw new WidgetMalformedMessageError(`sign-request.bytes is ${bytes.length} bytes; the contract caps it at ${MAX_SIGN_BYTES}`);
241
+ }
167
242
  ensureListener();
168
243
  const SIGN_TIMEOUT_MS = 60000;
169
244
  const requestId = generateId();
170
245
  return new Promise((resolve, reject) => {
171
246
  const timer = setTimeout(() => {
172
247
  pending.delete(requestId);
173
- reject(new Error('widget signing timed out — the vault modal may have closed'));
248
+ reject(new Error('widget signing timed out — the host page may have closed the widget'));
174
249
  }, SIGN_TIMEOUT_MS);
175
250
  pending.set(requestId, {
176
251
  resolve: (data) => {
177
252
  clearTimeout(timer);
178
253
  const d = data;
179
- if (d.error) {
254
+ if (isUnsupportedVersion(d.v)) {
255
+ reject(new WidgetProtocolVersionError(`the host replied in protocol v${String(d.v)}; this build speaks v${PROTOCOL_VERSION}`));
256
+ }
257
+ else if (d.errorCode === UNSUPPORTED_VERSION_CODE) {
258
+ reject(new WidgetProtocolVersionError(`the host rejected protocol v${PROTOCOL_VERSION} (${d.error ?? 'no detail'})`));
259
+ }
260
+ else if (d.error) {
180
261
  reject(new Error(d.error));
181
262
  }
182
263
  else if (d.signature === null) {
183
264
  reject(new Error('widget signing was declined'));
184
265
  }
266
+ else if (!isByteArray(d.signature)) {
267
+ // Without this the Uint8Array.from below throws out of the DOM
268
+ // listener — after clearTimeout — and sign() never settles at all.
269
+ reject(new WidgetMalformedMessageError('sign-response.signature must be null or an array of integers 0-255'));
270
+ }
185
271
  else {
186
272
  resolve(Uint8Array.from(d.signature));
187
273
  }
@@ -191,11 +277,11 @@ async function sign(bytes) {
191
277
  reject(reason);
192
278
  },
193
279
  });
194
- // targetOrigin '*': the widget doesn't know the Vault host's origin (it's
280
+ // targetOrigin '*': the widget doesn't know the host's origin (it's
195
281
  // not in the manifest), and the host is trusted. Safe because replies are
196
282
  // gated on `event.source === window.parent` — only the real parent can
197
283
  // answer. If the manifest ever carries the host origin, pin it here.
198
- window.parent.postMessage({ type: SIGN_REQUEST_TYPE, requestId, bytes: Array.from(bytes) }, '*');
284
+ window.parent.postMessage({ type: SIGN_REQUEST_TYPE, requestId, v: PROTOCOL_VERSION, bytes: Array.from(bytes) }, '*');
199
285
  });
200
286
  }
201
287
  /**
package/dist/index.d.ts CHANGED
@@ -21,5 +21,9 @@ export { encodeWav } from './audio/wav.js';
21
21
  export { segmentPcm, decodeToMono16k } from './audio/segment.js';
22
22
  export { requestMicStream } from './audio/mic.js';
23
23
  export { createHostBridge, type HostBridge } from './host-bridge.js';
24
+ export { IDENTITY_REQUEST_TYPE, IDENTITY_RESPONSE_TYPE, SIGN_REQUEST_TYPE, SIGN_RESPONSE_TYPE, PROTOCOL_VERSION, UNSUPPORTED_VERSION_CODE, MAX_SIGN_BYTES, isByteArray, WidgetProtocolVersionError, WidgetMalformedMessageError, } from './host-bridge.js';
25
+ export type { SigType, IdentityRequest, IdentityResponse, SignRequest, SignResponse, } from './host-bridge.js';
26
+ export { createWidgetHost, type WidgetHostOptions, type WidgetHostIdentity } from './widget-host.js';
27
+ export { WidgetSignedOutError, type ResolvedSigningIdentity } from './resolve-identity.js';
24
28
  export { toVaultSdkSigner, toClientSdkWallet, toUploaderSignerPair, type VaultSignedWallet } from './bridge-wallet.js';
25
29
  export { connectStandaloneWallet, type StandaloneWallet } from './standalone-wallet.js';
package/dist/index.js CHANGED
@@ -17,5 +17,14 @@ export { encodeWav } from './audio/wav.js';
17
17
  export { segmentPcm, decodeToMono16k } from './audio/segment.js';
18
18
  export { requestMicStream } from './audio/mic.js';
19
19
  export { createHostBridge } from './host-bridge.js';
20
+ // ── Widget↔host contract (see README "Host contract") ───────────────────────
21
+ // A host page implements the two RESPONSE shapes; it never constructs requests
22
+ // — but it must PARSE them, so the request types ship too. `MAX_SIGN_BYTES`,
23
+ // `UNSUPPORTED_VERSION_CODE` and `isByteArray` are the contract rules the
24
+ // README hands a hand-rolled host: exported so nobody has to re-type them and
25
+ // then drift when they move.
26
+ export { IDENTITY_REQUEST_TYPE, IDENTITY_RESPONSE_TYPE, SIGN_REQUEST_TYPE, SIGN_RESPONSE_TYPE, PROTOCOL_VERSION, UNSUPPORTED_VERSION_CODE, MAX_SIGN_BYTES, isByteArray, WidgetProtocolVersionError, WidgetMalformedMessageError, } from './host-bridge.js';
27
+ export { createWidgetHost } from './widget-host.js';
28
+ export { WidgetSignedOutError } from './resolve-identity.js';
20
29
  export { toVaultSdkSigner, toClientSdkWallet, toUploaderSignerPair } from './bridge-wallet.js';
21
30
  export { connectStandaloneWallet } from './standalone-wallet.js';