@cef-ai/widget-runtime 1.0.5 → 1.1.1
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 +169 -9
- package/dist/entry.js +11 -36
- package/dist/host-bridge.d.ts +71 -1
- package/dist/host-bridge.js +122 -36
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -0
- package/dist/resolve-identity.d.ts +26 -0
- package/dist/resolve-identity.js +58 -0
- package/dist/widget-host.d.ts +62 -0
- package/dist/widget-host.js +153 -0
- package/dist/widget-runtime.js +59 -59
- package/package.json +2 -2
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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();
|
package/dist/host-bridge.d.ts
CHANGED
|
@@ -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:
|
|
78
|
+
sigType: SigType;
|
|
9
79
|
} | null>;
|
|
10
80
|
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
11
81
|
}
|
package/dist/host-bridge.js
CHANGED
|
@@ -1,16 +1,73 @@
|
|
|
1
1
|
// packages/widget-runtime/src/host-bridge.ts
|
|
2
2
|
//
|
|
3
|
-
// postMessage client
|
|
4
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
* -
|
|
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
|
|
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.
|
|
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
|
|
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';
|