@enbox/browser 0.3.38 → 0.3.40
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/dist/browser.mjs +16 -16
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/browser-connect-handler.js +3 -3
- package/dist/esm/browser-connect-handler.js.map +1 -1
- package/dist/esm/dweb-connect-client.js +267 -190
- package/dist/esm/dweb-connect-client.js.map +1 -1
- package/dist/esm/dweb-connect-messages.js +54 -0
- package/dist/esm/dweb-connect-messages.js.map +1 -0
- package/dist/esm/dweb-connect-wallet.js +209 -0
- package/dist/esm/dweb-connect-wallet.js.map +1 -0
- package/dist/esm/index.js +9 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/ui/wallet-selector.js +6 -2
- package/dist/esm/ui/wallet-selector.js.map +1 -1
- package/dist/types/dweb-connect-client.d.ts +123 -59
- package/dist/types/dweb-connect-client.d.ts.map +1 -1
- package/dist/types/dweb-connect-messages.d.ts +64 -0
- package/dist/types/dweb-connect-messages.d.ts.map +1 -0
- package/dist/types/dweb-connect-wallet.d.ts +101 -0
- package/dist/types/dweb-connect-wallet.d.ts.map +1 -0
- package/dist/types/index.d.ts +8 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/ui/wallet-selector.d.ts.map +1 -1
- package/package.json +9 -6
- package/src/browser-connect-handler.ts +4 -4
- package/src/dweb-connect-client.ts +327 -251
- package/src/dweb-connect-messages.ts +91 -0
- package/src/dweb-connect-wallet.ts +258 -0
- package/src/index.ts +24 -5
- package/src/ui/wallet-selector.ts +7 -2
- package/dist/esm/dweb-connect-crypto.js +0 -112
- package/dist/esm/dweb-connect-crypto.js.map +0 -1
- package/dist/types/dweb-connect-crypto.d.ts +0 -52
- package/dist/types/dweb-connect-crypto.d.ts.map +0 -1
- package/src/dweb-connect-crypto.ts +0 -156
|
@@ -1,63 +1,333 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DWeb Connect client —
|
|
2
|
+
* DWeb Connect client — the dapp side of the popup/postMessage connect flow.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* {@link PopupClientTransport} implements the `@enbox/connect` kernel's
|
|
5
|
+
* `ConnectTransport` over a wallet popup:
|
|
6
|
+
*
|
|
7
|
+
* 1. The dapp opens `${walletUrl}/dweb-connect` as a popup window.
|
|
8
|
+
* 2. The wallet emits a `loaded` beacon carrying a fresh ephemeral X25519
|
|
9
|
+
* public key and its origin.
|
|
10
|
+
* 3. The kernel seals the signed connect request to that key (ECDH-ES,
|
|
11
|
+
* `apv` bound to the wallet origin) and the transport posts the
|
|
12
|
+
* ciphertext to the popup.
|
|
13
|
+
* 4. The wallet posts back the sealed response JWE (or the deny token),
|
|
14
|
+
* which the kernel opens and value-checks.
|
|
15
|
+
*
|
|
16
|
+
* Only ciphertext crosses the postMessage channel. Every message is checked
|
|
17
|
+
* against the wallet origin derived from the configured wallet URL (never
|
|
18
|
+
* `'*'`), and against the popup window as `event.source`.
|
|
7
19
|
*
|
|
8
20
|
* @module
|
|
9
21
|
*/
|
|
10
22
|
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
13
|
-
|
|
23
|
+
import type { Jwk } from '@enbox/crypto';
|
|
24
|
+
import type {
|
|
25
|
+
ConnectClientMetadata,
|
|
26
|
+
ConnectPermissionRequest,
|
|
27
|
+
ConnectRequestProfile,
|
|
28
|
+
ConnectResult,
|
|
29
|
+
ConnectTransport,
|
|
30
|
+
} from '@enbox/connect';
|
|
31
|
+
|
|
32
|
+
import { assertX25519PublicJwk, CONNECT_DENIED_TOKEN, ConnectClient } from '@enbox/connect';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
DWEB_CONNECT_LOADED_MESSAGE_TYPE,
|
|
36
|
+
DWEB_CONNECT_PATH,
|
|
37
|
+
DWEB_CONNECT_REQUEST_MESSAGE_TYPE,
|
|
38
|
+
DWEB_CONNECT_RESPONSE_MESSAGE_TYPE,
|
|
39
|
+
getTrustedMessage,
|
|
40
|
+
} from './dweb-connect-messages.js';
|
|
41
|
+
|
|
42
|
+
/** Default budget for the whole popup handshake (beacon + user consent + response). */
|
|
43
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
44
|
+
|
|
45
|
+
/** Interval at which the transport checks whether the user closed the popup. */
|
|
46
|
+
const CLOSED_POLL_INTERVAL_MS = 500;
|
|
47
|
+
|
|
48
|
+
/** Window name given to the wallet popup. */
|
|
49
|
+
const POPUP_WINDOW_NAME = 'enbox-dweb-connect';
|
|
14
50
|
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
/** Window features given to the wallet popup. */
|
|
52
|
+
const POPUP_WINDOW_FEATURES = 'width=500,height=700,menubar=no,toolbar=no,location=no,status=no';
|
|
53
|
+
|
|
54
|
+
/** Guards against calling browser-only entry points outside a browser. */
|
|
55
|
+
function assertBrowserEnvironment(): void {
|
|
56
|
+
if (typeof window === 'undefined') {
|
|
57
|
+
throw new Error('[@enbox/browser] DWeb Connect is only available in browser environments.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Thrown when the user closes the wallet popup before the wallet finished
|
|
63
|
+
* loading (i.e. before its `loaded` beacon arrived). Callers typically treat
|
|
64
|
+
* this the same as a user denial.
|
|
65
|
+
*/
|
|
66
|
+
export class PopupWindowClosedError extends Error {
|
|
67
|
+
constructor() {
|
|
68
|
+
super('[@enbox/browser] Wallet popup was closed before the wallet finished loading.');
|
|
69
|
+
this.name = 'PopupWindowClosedError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
17
72
|
|
|
18
|
-
/** Options for
|
|
19
|
-
export
|
|
20
|
-
/** Base URL of the wallet app (e.g.
|
|
73
|
+
/** Options for constructing a {@link PopupClientTransport}. */
|
|
74
|
+
export type PopupClientTransportOptions = {
|
|
75
|
+
/** Base URL of the wallet app (e.g. `https://enbox-wallet.pages.dev`). */
|
|
21
76
|
walletUrl: string;
|
|
22
77
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Total budget in milliseconds for the popup handshake. The popup is closed
|
|
80
|
+
* and the handshake rejected when the wallet has not responded in time.
|
|
81
|
+
* @default 300_000 (5 minutes)
|
|
82
|
+
*/
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Popup/postMessage {@link ConnectTransport} for browser dapps.
|
|
88
|
+
*
|
|
89
|
+
* Single-use: construct a new transport per handshake. The **constructor**
|
|
90
|
+
* opens the wallet popup synchronously — so `window.open` runs inside the user
|
|
91
|
+
* gesture, ahead of the kernel's awaited key generation, and survives strict
|
|
92
|
+
* popup blockers; `requestProfile()` then awaits the wallet's `loaded` beacon;
|
|
93
|
+
* `deliverRequest()` posts the sealed request ciphertext; `awaitResponse()`
|
|
94
|
+
* resolves with the sealed response JWE, or the deny token when the user denies
|
|
95
|
+
* or closes the popup after the handshake was established.
|
|
96
|
+
*
|
|
97
|
+
* Security properties:
|
|
98
|
+
* - `targetOrigin` of every outbound `postMessage` is pinned to the origin
|
|
99
|
+
* derived from `walletUrl` — never `'*'`.
|
|
100
|
+
* - Inbound messages are dropped unless `event.origin` equals the pinned
|
|
101
|
+
* wallet origin AND `event.source` is the popup window this transport opened.
|
|
102
|
+
* - The beacon's `walletOrigin` field must equal the pinned origin, and its
|
|
103
|
+
* `walletEpk` must be a public-only X25519 JWK; a malformed beacon from the
|
|
104
|
+
* genuine popup fails the handshake closed.
|
|
105
|
+
*/
|
|
106
|
+
export class PopupClientTransport implements ConnectTransport {
|
|
107
|
+
/** {@inheritDoc ConnectTransport.requiresPin} */
|
|
108
|
+
public readonly requiresPin: boolean = false;
|
|
109
|
+
|
|
110
|
+
private readonly _walletOrigin: string;
|
|
111
|
+
private readonly _timeoutMs: number;
|
|
112
|
+
|
|
113
|
+
private readonly _popup: Window;
|
|
114
|
+
private _settled = false;
|
|
115
|
+
private _walletEpkReceived = false;
|
|
116
|
+
private _timeoutId?: ReturnType<typeof setTimeout>;
|
|
117
|
+
private _closedPollId?: ReturnType<typeof setInterval>;
|
|
118
|
+
private _messageListener?: (event: MessageEvent) => void;
|
|
119
|
+
|
|
120
|
+
private readonly _walletEpkPromise: Promise<Jwk>;
|
|
121
|
+
private _resolveWalletEpk?: (walletEpk: Jwk) => void;
|
|
122
|
+
private _rejectWalletEpk?: (error: Error) => void;
|
|
123
|
+
|
|
124
|
+
private readonly _responsePromise: Promise<string>;
|
|
125
|
+
private _resolveResponse?: (payload: string) => void;
|
|
126
|
+
private _rejectResponse?: (error: Error) => void;
|
|
127
|
+
|
|
128
|
+
constructor(options: PopupClientTransportOptions) {
|
|
129
|
+
assertBrowserEnvironment();
|
|
130
|
+
|
|
131
|
+
this._walletOrigin = new URL(options.walletUrl).origin;
|
|
132
|
+
this._timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
133
|
+
|
|
134
|
+
// Open the wallet popup SYNCHRONOUSLY, in the user-gesture call stack,
|
|
135
|
+
// before the kernel's awaited `DidJwk.create()` / `X25519.generateKey()`
|
|
136
|
+
// run. Strict popup blockers (Safari) reject a `window.open` issued once
|
|
137
|
+
// the stack has yielded to a prior `await`, so opening it here — instead of
|
|
138
|
+
// inside the awaited `requestProfile()` — is what keeps the flow usable.
|
|
139
|
+
const popupUrl = new URL(DWEB_CONNECT_PATH, options.walletUrl).toString();
|
|
140
|
+
const popup = window.open(popupUrl, POPUP_WINDOW_NAME, POPUP_WINDOW_FEATURES);
|
|
141
|
+
if (!popup) {
|
|
142
|
+
throw new Error('[@enbox/browser] Popup blocked. Allow popups for this site to connect to a wallet.');
|
|
143
|
+
}
|
|
144
|
+
this._popup = popup;
|
|
145
|
+
|
|
146
|
+
this._walletEpkPromise = new Promise<Jwk>((resolve, reject): void => {
|
|
147
|
+
this._resolveWalletEpk = resolve;
|
|
148
|
+
this._rejectWalletEpk = reject;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Created up-front so a response (or deny) arriving before
|
|
152
|
+
// `awaitResponse()` is called is never dropped. The no-op catch marks
|
|
153
|
+
// early rejections (timeout, popup closed during load) as handled until
|
|
154
|
+
// the kernel attaches its own handlers via `awaitResponse()`.
|
|
155
|
+
this._responsePromise = new Promise<string>((resolve, reject): void => {
|
|
156
|
+
this._resolveResponse = resolve;
|
|
157
|
+
this._rejectResponse = reject;
|
|
158
|
+
});
|
|
159
|
+
this._responsePromise.catch((): undefined => undefined);
|
|
160
|
+
|
|
161
|
+
// Listen synchronously, before the wallet page can emit its `loaded`
|
|
162
|
+
// beacon, so the beacon is never missed while the kernel awaits key
|
|
163
|
+
// generation ahead of `requestProfile()`.
|
|
164
|
+
this._messageListener = (event: MessageEvent): void => { this.onMessage(event); };
|
|
165
|
+
window.addEventListener('message', this._messageListener);
|
|
166
|
+
|
|
167
|
+
this._timeoutId = setTimeout((): void => {
|
|
168
|
+
this.fail(new Error('[@enbox/browser] DWeb Connect timed out waiting for wallet response.'));
|
|
169
|
+
}, this._timeoutMs);
|
|
170
|
+
|
|
171
|
+
this._closedPollId = setInterval((): void => { this.onClosedPoll(); }, CLOSED_POLL_INTERVAL_MS);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Awaits the already-open popup's `loaded` beacon, then returns the popup
|
|
176
|
+
* channel profile: ECDH-ES encryption to the beacon's ephemeral key with the
|
|
177
|
+
* pinned wallet origin as the `apv` binding, a `post_message` reply
|
|
178
|
+
* descriptor, and the client-supplied `state` correlator.
|
|
179
|
+
*
|
|
180
|
+
* The popup was opened synchronously at construction, so this method only
|
|
181
|
+
* awaits the beacon — it never issues the blocker-sensitive `window.open`.
|
|
182
|
+
*
|
|
183
|
+
* @param state - The handshake correlator minted by the {@link ConnectClient}.
|
|
184
|
+
* @throws If the user closes the popup before the beacon arrives
|
|
185
|
+
* ({@link PopupWindowClosedError}), the beacon is malformed, or on
|
|
186
|
+
* timeout.
|
|
187
|
+
*/
|
|
188
|
+
public async requestProfile(state: string): Promise<ConnectRequestProfile> {
|
|
189
|
+
const walletEpk = await this._walletEpkPromise;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
encryption : { mode: 'ecdh-es', walletEpk, walletOrigin: this._walletOrigin },
|
|
193
|
+
reply : { mode: 'post_message' },
|
|
194
|
+
state,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
25
197
|
|
|
26
198
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
199
|
+
* Posts the sealed request ciphertext to the wallet popup with the
|
|
200
|
+
* `targetOrigin` pinned to the wallet origin.
|
|
29
201
|
*/
|
|
202
|
+
public async deliverRequest(jwe: string): Promise<void> {
|
|
203
|
+
this._popup.postMessage({ type: DWEB_CONNECT_REQUEST_MESSAGE_TYPE, jwe }, this._walletOrigin);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Awaits the wallet's response ciphertext: the sealed response JWE, or the
|
|
208
|
+
* deny token when the user denies in the wallet or closes the popup.
|
|
209
|
+
* Rejects on timeout.
|
|
210
|
+
*/
|
|
211
|
+
public async awaitResponse(): Promise<string> {
|
|
212
|
+
return await this._responsePromise;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private onMessage(event: MessageEvent): void {
|
|
216
|
+
const message = getTrustedMessage(event, this._walletOrigin, this._popup);
|
|
217
|
+
if (message === undefined) { return; }
|
|
218
|
+
|
|
219
|
+
if (message.type === DWEB_CONNECT_LOADED_MESSAGE_TYPE) {
|
|
220
|
+
this.onLoadedBeacon(message);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (message.type === DWEB_CONNECT_RESPONSE_MESSAGE_TYPE) {
|
|
225
|
+
this.onResponseMessage(message);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private onLoadedBeacon(message: Record<string, unknown>): void {
|
|
230
|
+
if (this._settled || this._walletEpkReceived) { return; }
|
|
231
|
+
|
|
232
|
+
// The beacon passed the origin and source checks, so a malformed payload
|
|
233
|
+
// is a protocol violation by the genuine popup — fail closed. The X25519
|
|
234
|
+
// public-JWK shape check is delegated to the connect kernel.
|
|
235
|
+
let walletEpk: Jwk;
|
|
236
|
+
try {
|
|
237
|
+
assertX25519PublicJwk(message.walletEpk);
|
|
238
|
+
walletEpk = message.walletEpk;
|
|
239
|
+
} catch {
|
|
240
|
+
this.fail(new Error('[@enbox/browser] Wallet loaded beacon did not carry a valid X25519 public key.'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (message.walletOrigin !== this._walletOrigin) {
|
|
245
|
+
this.fail(new Error('[@enbox/browser] Wallet loaded beacon origin does not match the configured wallet origin.'));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this._walletEpkReceived = true;
|
|
250
|
+
this._resolveWalletEpk?.(walletEpk);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private onResponseMessage(message: Record<string, unknown>): void {
|
|
254
|
+
if (this._settled) { return; }
|
|
255
|
+
|
|
256
|
+
if (typeof message.payload !== 'string') {
|
|
257
|
+
this.fail(new Error('[@enbox/browser] Wallet response message did not carry a ciphertext payload.'));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this._settled = true;
|
|
262
|
+
this.cleanup();
|
|
263
|
+
// No-op on the normal path; unblocks `requestProfile()` if a response
|
|
264
|
+
// somehow arrives before the beacon.
|
|
265
|
+
this._rejectWalletEpk?.(new Error('[@enbox/browser] Wallet responded before the handshake was established.'));
|
|
266
|
+
this._resolveResponse?.(message.payload);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private onClosedPoll(): void {
|
|
270
|
+
if (this._settled || this._popup.closed !== true) { return; }
|
|
271
|
+
|
|
272
|
+
if (!this._walletEpkReceived) {
|
|
273
|
+
// Closed before the wallet ever loaded — the handshake cannot proceed.
|
|
274
|
+
this.fail(new PopupWindowClosedError());
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Closed after the handshake was established — treat as a user denial.
|
|
279
|
+
this._settled = true;
|
|
280
|
+
this.cleanup();
|
|
281
|
+
this._resolveResponse?.(CONNECT_DENIED_TOKEN);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private fail(error: Error): void {
|
|
285
|
+
if (this._settled) { return; }
|
|
286
|
+
this._settled = true;
|
|
287
|
+
this.cleanup();
|
|
288
|
+
try { this._popup.close(); } catch { /* best effort */ }
|
|
289
|
+
this._rejectWalletEpk?.(error);
|
|
290
|
+
this._rejectResponse?.(error);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private cleanup(): void {
|
|
294
|
+
clearTimeout(this._timeoutId);
|
|
295
|
+
clearInterval(this._closedPollId);
|
|
296
|
+
if (this._messageListener !== undefined) {
|
|
297
|
+
window.removeEventListener('message', this._messageListener);
|
|
298
|
+
this._messageListener = undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Options for a one-shot {@link connectViaPopup} flow. */
|
|
304
|
+
export type PopupConnectOptions = {
|
|
305
|
+
/** Base URL of the wallet app (e.g. `https://enbox-wallet.pages.dev`). */
|
|
306
|
+
walletUrl: string;
|
|
307
|
+
|
|
308
|
+
/** DWN protocols and permission scopes to request from the wallet. */
|
|
30
309
|
permissionRequests: ConnectPermissionRequest[];
|
|
31
310
|
|
|
32
311
|
/**
|
|
33
|
-
* Timeout in milliseconds
|
|
34
|
-
* if the wallet doesn't respond within this time.
|
|
312
|
+
* Timeout in milliseconds for the popup handshake.
|
|
35
313
|
* @default 300_000 (5 minutes)
|
|
36
314
|
*/
|
|
37
315
|
timeout?: number;
|
|
38
316
|
|
|
39
317
|
/**
|
|
40
|
-
* Display name of the requesting application
|
|
41
|
-
*
|
|
318
|
+
* Display name of the requesting application, shown in the wallet's
|
|
319
|
+
* consent screen. Defaults to the dapp's host.
|
|
42
320
|
*/
|
|
43
321
|
appName?: string;
|
|
44
322
|
|
|
45
323
|
/**
|
|
46
|
-
* Icon URL of the requesting application
|
|
47
|
-
*
|
|
324
|
+
* Icon URL of the requesting application, shown alongside the app name in
|
|
325
|
+
* the wallet's consent screen.
|
|
48
326
|
*/
|
|
49
327
|
appIcon?: string;
|
|
328
|
+
};
|
|
50
329
|
|
|
51
|
-
|
|
52
|
-
* A portable identity to transfer to the wallet for import.
|
|
53
|
-
*
|
|
54
|
-
* When provided, the wallet imports this identity and creates
|
|
55
|
-
* delegate grants for the dapp, allowing the dapp to transition
|
|
56
|
-
* from local-DID mode to delegate mode while keeping the same DID.
|
|
57
|
-
*/
|
|
58
|
-
portableIdentity?: PortableIdentity;
|
|
59
|
-
}
|
|
60
|
-
|
|
330
|
+
/** Collects self-reported environment hints for the wallet's session display. */
|
|
61
331
|
function collectBrowserClientMetadata(): ConnectClientMetadata {
|
|
62
332
|
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
|
63
333
|
const languages = Array.isArray(globalThis.navigator.languages)
|
|
@@ -75,228 +345,34 @@ function collectBrowserClientMetadata(): ConnectClientMetadata {
|
|
|
75
345
|
}
|
|
76
346
|
|
|
77
347
|
/**
|
|
78
|
-
*
|
|
348
|
+
* Runs one DWeb Connect popup handshake end-to-end: opens the wallet popup,
|
|
349
|
+
* drives the `@enbox/connect` kernel client over a {@link PopupClientTransport},
|
|
350
|
+
* and returns the delegated credentials.
|
|
79
351
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* appName?, appIcon?, portableIdentity? }`.
|
|
85
|
-
* 4. Wallet shows consent UI, then sends back
|
|
86
|
-
* `{ type: 'dweb-connect-authorization-response', delegateDid, grants }`.
|
|
87
|
-
* 5. Popup closes.
|
|
88
|
-
*
|
|
89
|
-
* @returns The delegate credentials, or `undefined` if the user denied.
|
|
90
|
-
* @throws If the popup is blocked, times out, or the wallet returns an error.
|
|
352
|
+
* @returns The delegate credentials, or `undefined` when the user denied the
|
|
353
|
+
* request or closed the popup.
|
|
354
|
+
* @throws If the popup is blocked, the handshake times out, or the wallet
|
|
355
|
+
* returns an invalid response.
|
|
91
356
|
*/
|
|
92
|
-
async function
|
|
93
|
-
const {
|
|
94
|
-
walletUrl,
|
|
95
|
-
|
|
96
|
-
permissionRequests,
|
|
97
|
-
timeout = 300_000,
|
|
98
|
-
appName,
|
|
99
|
-
appIcon,
|
|
100
|
-
portableIdentity,
|
|
101
|
-
} = options;
|
|
102
|
-
|
|
103
|
-
if (typeof window === 'undefined') {
|
|
104
|
-
throw new Error(
|
|
105
|
-
'[@enbox/browser] DWeb Connect is only available in browser environments.'
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Open the wallet's DWeb Connect page as a popup.
|
|
110
|
-
const popupUrl = new URL('/dweb-connect', walletUrl).toString();
|
|
111
|
-
const popup = window.open(
|
|
112
|
-
popupUrl,
|
|
113
|
-
'enbox-dweb-connect',
|
|
114
|
-
'width=500,height=700,menubar=no,toolbar=no,location=no,status=no',
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
if (!popup) {
|
|
118
|
-
throw new Error(
|
|
119
|
-
'[@enbox/browser] Popup blocked. Allow popups for this site to connect to a wallet.'
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Generate an ephemeral ECDH keypair for this connect session.
|
|
124
|
-
// The public key is sent to the wallet so it can encrypt the response
|
|
125
|
-
// containing delegate private keys and decryption material.
|
|
126
|
-
// Encryption is mandatory — if key generation fails (e.g. non-secure
|
|
127
|
-
// context where crypto.subtle is unavailable), the connect flow aborts.
|
|
128
|
-
let dappKeyPair: CryptoKeyPair;
|
|
129
|
-
let dappPublicKeyBase64url: string;
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const ephemeral = await generateEphemeralKeyPair();
|
|
133
|
-
dappKeyPair = ephemeral.keyPair;
|
|
134
|
-
dappPublicKeyBase64url = ephemeral.publicKeyBase64url;
|
|
135
|
-
} catch {
|
|
136
|
-
popup.close();
|
|
137
|
-
throw new Error(
|
|
138
|
-
'[@enbox/browser] DWeb Connect requires a secure context (HTTPS) for encrypted key exchange. '
|
|
139
|
-
+ 'crypto.subtle is not available in this environment.'
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return new Promise<ConnectResult | undefined>((resolve, reject) => {
|
|
144
|
-
let settled = false;
|
|
145
|
-
|
|
146
|
-
const cleanup = (): void => {
|
|
147
|
-
settled = true;
|
|
148
|
-
clearTimeout(timeoutId);
|
|
149
|
-
window.removeEventListener('message', onMessage);
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
// Set up timeout.
|
|
153
|
-
const timeoutId = setTimeout(() => {
|
|
154
|
-
if (!settled) {
|
|
155
|
-
cleanup();
|
|
156
|
-
try { popup.close(); } catch { /* best effort */ }
|
|
157
|
-
reject(new Error(
|
|
158
|
-
'[@enbox/browser] DWeb Connect timed out waiting for wallet response.'
|
|
159
|
-
));
|
|
160
|
-
}
|
|
161
|
-
}, timeout);
|
|
162
|
-
|
|
163
|
-
// Monitor popup closed without response (user closed the window).
|
|
164
|
-
const pollClosed = setInterval(() => {
|
|
165
|
-
if (popup.closed && !settled) {
|
|
166
|
-
clearInterval(pollClosed);
|
|
167
|
-
cleanup();
|
|
168
|
-
resolve(undefined);
|
|
169
|
-
}
|
|
170
|
-
}, 500);
|
|
171
|
-
|
|
172
|
-
const onMessage = (event: MessageEvent): void => {
|
|
173
|
-
// Validate origin matches the wallet URL.
|
|
174
|
-
const walletOrigin = new URL(walletUrl).origin;
|
|
175
|
-
if (event.origin !== walletOrigin) {return;}
|
|
176
|
-
|
|
177
|
-
const { type } = event.data ?? {};
|
|
178
|
-
|
|
179
|
-
if (type === 'dweb-connect-loaded') {
|
|
180
|
-
// Wallet is ready — send the authorization request with
|
|
181
|
-
// ephemeral public key and optional app metadata.
|
|
182
|
-
popup.postMessage({
|
|
183
|
-
type : 'dweb-connect-authorization-request',
|
|
184
|
-
did,
|
|
185
|
-
permissions : permissionRequests,
|
|
186
|
-
ephemeralPublicKey : dappPublicKeyBase64url,
|
|
187
|
-
appName,
|
|
188
|
-
appIcon,
|
|
189
|
-
clientMetadata : collectBrowserClientMetadata(),
|
|
190
|
-
portableIdentity,
|
|
191
|
-
}, walletOrigin);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (type === 'dweb-connect-authorization-response') {
|
|
196
|
-
clearInterval(pollClosed);
|
|
197
|
-
cleanup();
|
|
198
|
-
|
|
199
|
-
// If the wallet sent an explicit error, treat as denied.
|
|
200
|
-
if (event.data.error) {
|
|
201
|
-
resolve(undefined);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Require an encrypted response. The dapp always sends an
|
|
206
|
-
// ephemeralPublicKey, so a plaintext response means either the
|
|
207
|
-
// wallet is outdated or something intercepted the flow.
|
|
208
|
-
const encrypted = event.data.encryptedPayload as EncryptedPostMessagePayload | undefined;
|
|
209
|
-
if (!encrypted) {
|
|
210
|
-
resolve(undefined);
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
decryptPostMessagePayload(encrypted, dappKeyPair).then((payload: Record<string, unknown>) => {
|
|
215
|
-
const p = payload as any;
|
|
216
|
-
if (!p.delegateDid || !p.grants) {
|
|
217
|
-
resolve(undefined);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
resolve({
|
|
221
|
-
delegatePortableDid : p.delegateDid as PortableDid,
|
|
222
|
-
delegateGrants : p.grants as DwnDataEncodedRecordsWriteMessage[],
|
|
223
|
-
connectedDid : p.connectedDid ?? did ?? (p.delegateDid as PortableDid).uri,
|
|
224
|
-
sessionRevocations : p.sessionRevocations ?? undefined,
|
|
225
|
-
});
|
|
226
|
-
}).catch(() => {
|
|
227
|
-
// Decryption failed — treat as denied.
|
|
228
|
-
resolve(undefined);
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
window.addEventListener('message', onMessage);
|
|
357
|
+
export async function connectViaPopup(options: PopupConnectOptions): Promise<ConnectResult | undefined> {
|
|
358
|
+
const transport = new PopupClientTransport({
|
|
359
|
+
walletUrl : options.walletUrl,
|
|
360
|
+
timeoutMs : options.timeout,
|
|
234
361
|
});
|
|
235
|
-
}
|
|
362
|
+
const client = new ConnectClient({ transport });
|
|
236
363
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
*
|
|
244
|
-
* @param walletUrl - Base URL of the wallet to probe.
|
|
245
|
-
* @param did - The DID to check.
|
|
246
|
-
* @param timeout - Probe timeout in ms. Default: 5_000 (5 seconds).
|
|
247
|
-
* @returns `true` if the wallet manages the DID, `false` otherwise.
|
|
248
|
-
*/
|
|
249
|
-
async function probeWalletSupport(
|
|
250
|
-
walletUrl: string,
|
|
251
|
-
did: string,
|
|
252
|
-
timeout = 5_000,
|
|
253
|
-
): Promise<boolean> {
|
|
254
|
-
if (typeof window === 'undefined') {return false;}
|
|
255
|
-
|
|
256
|
-
return new Promise<boolean>((resolve) => {
|
|
257
|
-
const iframe = document.createElement('iframe');
|
|
258
|
-
iframe.style.display = 'none';
|
|
259
|
-
iframe.src = walletUrl;
|
|
260
|
-
|
|
261
|
-
let settled = false;
|
|
262
|
-
|
|
263
|
-
const cleanup = (): void => {
|
|
264
|
-
settled = true;
|
|
265
|
-
window.removeEventListener('message', onMessage);
|
|
266
|
-
try { document.body.removeChild(iframe); } catch { /* best effort */ }
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const timeoutId = setTimeout(() => {
|
|
270
|
-
if (!settled) {
|
|
271
|
-
cleanup();
|
|
272
|
-
resolve(false);
|
|
273
|
-
}
|
|
274
|
-
}, timeout);
|
|
275
|
-
|
|
276
|
-
const onMessage = (event: MessageEvent): void => {
|
|
277
|
-
const walletOrigin = new URL(walletUrl).origin;
|
|
278
|
-
if (event.origin !== walletOrigin) { return; }
|
|
279
|
-
|
|
280
|
-
const { type, supported } = event.data ?? {};
|
|
281
|
-
if (type === 'dweb-connect-support-response') {
|
|
282
|
-
clearTimeout(timeoutId);
|
|
283
|
-
cleanup();
|
|
284
|
-
resolve(!!supported);
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
window.addEventListener('message', onMessage);
|
|
289
|
-
|
|
290
|
-
iframe.addEventListener('load', () => {
|
|
291
|
-
const walletOrigin = new URL(walletUrl).origin;
|
|
292
|
-
iframe.contentWindow?.postMessage({
|
|
293
|
-
type: 'dweb-connect-support-request',
|
|
294
|
-
did,
|
|
295
|
-
}, walletOrigin);
|
|
364
|
+
try {
|
|
365
|
+
return await client.connect({
|
|
366
|
+
appName : options.appName ?? globalThis.location.host,
|
|
367
|
+
appIcon : options.appIcon,
|
|
368
|
+
clientMetadata : collectBrowserClientMetadata(),
|
|
369
|
+
permissionRequests : options.permissionRequests,
|
|
296
370
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error instanceof PopupWindowClosedError) {
|
|
373
|
+
// The user closed the wallet before it loaded — same as a denial.
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
300
378
|
}
|
|
301
|
-
|
|
302
|
-
export const DWebConnect = { initClient, probeWalletSupport };
|