@openkey/sdk 0.9.0 → 0.10.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/dist/index.mjs CHANGED
@@ -1,16 +1,317 @@
1
+ // ../core/dist/index.js
2
+ var u = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3
+ function g(e) {
4
+ let r = "", n = e.length;
5
+ for (let t = 0; t < n; t += 3) {
6
+ let c = e[t], d = t + 1 < n ? e[t + 1] : 0, i = t + 2 < n ? e[t + 2] : 0, o = c << 16 | d << 8 | i;
7
+ r += u[o >> 18 & 63], r += u[o >> 12 & 63], r += t + 1 < n ? u[o >> 6 & 63] : "", r += t + 2 < n ? u[o & 63] : "";
8
+ }
9
+ return r.replace(/\+/g, "-").replace(/\//g, "_");
10
+ }
11
+ function y() {
12
+ let e = new Uint8Array(32);
13
+ return crypto.getRandomValues(e), g(e);
14
+ }
15
+ async function x(e) {
16
+ let n = new TextEncoder().encode(e), t = await crypto.subtle.digest("SHA-256", n);
17
+ return new Uint8Array(t);
18
+ }
19
+ async function k(e, r) {
20
+ let t = await (r ?? x)(e);
21
+ return g(t);
22
+ }
23
+ function m() {
24
+ let e = new Uint8Array(16);
25
+ return crypto.getRandomValues(e), g(e);
26
+ }
27
+ function T(e) {
28
+ let { host: r, clientId: n, redirectUri: t, codeChallenge: c, state: d, scopes: i, authorizePath: o = "/api/auth/oauth2/authorize" } = e, s = new URL(o, r);
29
+ return s.searchParams.set("client_id", n), s.searchParams.set("redirect_uri", t), s.searchParams.set("response_type", "code"), s.searchParams.set("scope", i.join(" ")), s.searchParams.set("state", d), s.searchParams.set("code_challenge", c), s.searchParams.set("code_challenge_method", "S256"), s.toString();
30
+ }
31
+
32
+ // src/nostr.ts
33
+ var NOSTR_FRAME_TIMEOUT_MS = 5 * 60 * 1e3;
34
+ var NOSTR_PROTOCOL_VERSION = 1;
35
+ function newRequestId() {
36
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
37
+ return crypto.randomUUID();
38
+ }
39
+ if (typeof crypto === "undefined" || typeof crypto.getRandomValues !== "function") {
40
+ throw new Error("Secure request-id generation is unavailable.");
41
+ }
42
+ const bytes = new Uint32Array(4);
43
+ crypto.getRandomValues(bytes);
44
+ return `nostr-${Array.from(bytes, (value) => value.toString(16)).join("")}`;
45
+ }
46
+ var NostrFrame = class {
47
+ root;
48
+ shadow;
49
+ iframe;
50
+ outer;
51
+ host;
52
+ messageHandler = () => {
53
+ };
54
+ revealed = false;
55
+ requestedHeight = 400;
56
+ viewportHandler = () => {
57
+ this.syncViewport();
58
+ this.applyFrameHeight();
59
+ };
60
+ constructor(host, url) {
61
+ this.host = host;
62
+ this.root = document.createElement("div");
63
+ this.shadow = this.root.attachShadow({ mode: "closed" });
64
+ const style = document.createElement("style");
65
+ style.textContent = `
66
+ :host{all:initial}
67
+ .nf-outer{position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none}
68
+ .nf-outer.nf-visible{position:fixed;top:var(--nf-viewport-top,0px);left:var(--nf-viewport-left,0px);width:var(--nf-viewport-width,100vw);height:var(--nf-viewport-height,100dvh);box-sizing:border-box;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));overflow:auto;overscroll-behavior:contain;opacity:1;pointer-events:auto;background:rgba(0,0,0,0.42);z-index:999999;display:flex;align-items:center;justify-content:center}
69
+ .nf-card{width:1px;height:1px;overflow:hidden}
70
+ .nf-visible .nf-card{background:#fafafa;width:min(400px,100%);height:auto;max-height:100%;border-radius:16px;box-shadow:0 25px 50px -12px rgba(0,0,0,0.25);overflow:auto;flex:0 1 auto}
71
+ iframe{border:none;width:1px;height:1px;display:block;transition:height 200ms ease}
72
+ .nf-visible iframe{width:100%;height:400px;max-height:100%}
73
+ @media (prefers-reduced-motion:reduce){iframe{transition:none}}
74
+ `;
75
+ this.outer = document.createElement("div");
76
+ this.outer.className = "nf-outer";
77
+ this.outer.setAttribute("role", "dialog");
78
+ this.outer.setAttribute("aria-modal", "true");
79
+ this.outer.setAttribute("aria-label", "OpenKey approval");
80
+ this.outer.setAttribute("aria-hidden", "true");
81
+ const card = document.createElement("div");
82
+ card.className = "nf-card";
83
+ this.iframe = document.createElement("iframe");
84
+ this.iframe.src = url;
85
+ this.iframe.title = "OpenKey approval";
86
+ this.iframe.setAttribute("allow", "clipboard-write *; publickey-credentials-get *; publickey-credentials-create *");
87
+ this.iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
88
+ card.appendChild(this.iframe);
89
+ this.outer.appendChild(card);
90
+ this.shadow.appendChild(style);
91
+ this.shadow.appendChild(this.outer);
92
+ document.body.appendChild(this.root);
93
+ this.syncViewport();
94
+ window.addEventListener("resize", this.viewportHandler);
95
+ window.visualViewport?.addEventListener("resize", this.viewportHandler);
96
+ window.visualViewport?.addEventListener("scroll", this.viewportHandler);
97
+ }
98
+ onMessage(handler) {
99
+ this.messageHandler = (event) => {
100
+ if (event.origin !== this.host) return;
101
+ if (event.source !== this.iframe.contentWindow) return;
102
+ handler(event.data);
103
+ };
104
+ window.addEventListener("message", this.messageHandler);
105
+ }
106
+ reveal() {
107
+ if (this.revealed) return;
108
+ this.revealed = true;
109
+ this.syncViewport();
110
+ this.outer.classList.add("nf-visible");
111
+ this.outer.setAttribute("aria-hidden", "false");
112
+ this.applyFrameHeight();
113
+ }
114
+ resize(height) {
115
+ if (!Number.isFinite(height) || height <= 0) return;
116
+ this.requestedHeight = height;
117
+ if (this.revealed) this.applyFrameHeight();
118
+ }
119
+ syncViewport() {
120
+ const viewport = window.visualViewport;
121
+ this.outer.style.setProperty("--nf-viewport-top", `${viewport?.offsetTop ?? 0}px`);
122
+ this.outer.style.setProperty("--nf-viewport-left", `${viewport?.offsetLeft ?? 0}px`);
123
+ this.outer.style.setProperty("--nf-viewport-width", `${viewport?.width ?? window.innerWidth}px`);
124
+ this.outer.style.setProperty("--nf-viewport-height", `${viewport?.height ?? window.innerHeight}px`);
125
+ }
126
+ applyFrameHeight() {
127
+ if (!this.revealed) return;
128
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
129
+ const max = Math.max(1, Math.floor(viewportHeight - 32));
130
+ this.iframe.style.height = `${Math.min(this.requestedHeight, max)}px`;
131
+ }
132
+ postMessage(message) {
133
+ this.iframe.contentWindow?.postMessage(message, this.host);
134
+ }
135
+ destroy() {
136
+ window.removeEventListener("message", this.messageHandler);
137
+ window.removeEventListener("resize", this.viewportHandler);
138
+ window.visualViewport?.removeEventListener("resize", this.viewportHandler);
139
+ window.visualViewport?.removeEventListener("scroll", this.viewportHandler);
140
+ this.root.remove();
141
+ }
142
+ };
143
+ var OpenKeyNostr = class {
144
+ constructor(host) {
145
+ this.host = host;
146
+ }
147
+ /**
148
+ * Get-or-create the user's OpenKey-custodied Nostr identity. Always shows
149
+ * a visible consent card (identity creation, or - when `relayUrl` is
150
+ * given - a relay-scoped kind:22242/NIP-42 grant for that exact relay).
151
+ * Never resolves with the secret key; only `keyId`/`pubkey`/`npub`.
152
+ */
153
+ async connect(opts) {
154
+ const url = this.widgetUrl();
155
+ return this.run(
156
+ url,
157
+ { type: "openkey:nostr:connect:request", relayUrl: opts?.relayUrl },
158
+ (data, resolve, reject) => {
159
+ if (data.type !== "openkey:nostr:connect:response") return false;
160
+ if (data.success) resolve({ keyId: data.keyId, pubkey: data.pubkey, npub: data.npub });
161
+ else reject(data.error);
162
+ return true;
163
+ }
164
+ );
165
+ }
166
+ /**
167
+ * Sign a NIP-01 event template with the user's OpenKey Nostr key. Silent
168
+ * (no visible UI) when an active grant already covers this client origin
169
+ * + key + kind (+ exact relay, for kind 22242). Otherwise shows a visible
170
+ * consent card before signing - required for every first kind:9 message to
171
+ * a new origin, per NIP-29 channel-message policy.
172
+ */
173
+ async signEvent(keyId, event) {
174
+ const url = this.widgetUrl();
175
+ return this.run(
176
+ url,
177
+ { type: "openkey:nostr:sign:request", keyId, event },
178
+ (data, resolve, reject) => {
179
+ if (data.type !== "openkey:nostr:sign:response") return false;
180
+ if (data.success) resolve(data.event);
181
+ else reject(data.error);
182
+ return true;
183
+ }
184
+ );
185
+ }
186
+ widgetUrl() {
187
+ const origin = encodeURIComponent(window.location.origin);
188
+ return `${this.host}/widget/embed/nostr/approve?origin=${origin}`;
189
+ }
190
+ run(url, request, onData) {
191
+ return new Promise((resolve, reject) => {
192
+ let settled = false;
193
+ const frame = new NostrFrame(this.host, url);
194
+ const requestId = newRequestId();
195
+ const settle = (fn) => {
196
+ if (settled) return;
197
+ settled = true;
198
+ clearTimeout(timeout);
199
+ fn();
200
+ frame.destroy();
201
+ };
202
+ frame.onMessage((data) => {
203
+ if (!data || typeof data.type !== "string") return;
204
+ if (data.type === "openkey:ready") {
205
+ if (data.protocolVersion !== NOSTR_PROTOCOL_VERSION) return;
206
+ frame.postMessage({ ...request, requestId, protocolVersion: NOSTR_PROTOCOL_VERSION });
207
+ return;
208
+ }
209
+ if (data.requestId !== requestId || data.protocolVersion !== NOSTR_PROTOCOL_VERSION) return;
210
+ if (data.type === "openkey:resize") {
211
+ frame.resize(data.height);
212
+ return;
213
+ }
214
+ if (data.type === "openkey:nostr:show") {
215
+ frame.reveal();
216
+ return;
217
+ }
218
+ if (data.type === "openkey:close") {
219
+ settle(() => reject({ code: "USER_CANCELLED", message: "Request closed" }));
220
+ return;
221
+ }
222
+ onData(data, (v) => settle(() => resolve(v)), (e) => settle(() => reject(e)));
223
+ });
224
+ const timeout = setTimeout(() => {
225
+ settle(() => reject({ code: "TIMEOUT", message: "Nostr request timed out" }));
226
+ }, NOSTR_FRAME_TIMEOUT_MS);
227
+ });
228
+ }
229
+ };
230
+
1
231
  // src/index.ts
2
- import {
3
- generateCodeVerifier,
4
- generateCodeChallenge,
5
- generateState,
6
- buildAuthorizationUrl
7
- } from "@openkey/core";
8
232
  var DEFAULT_HOST = "https://openkey.so";
9
233
  var POPUP_WIDTH = 400;
10
234
  var POPUP_HEIGHT = 600;
11
235
  var DEFAULT_TIMEOUT = 5 * 60 * 1e3;
12
236
  var IFRAME_READY_TIMEOUT = 3e3;
13
237
  var OAUTH_STORAGE_KEY = "openkey_oauth";
238
+ function validateIframeResize(incoming, expected) {
239
+ if (!incoming || typeof incoming !== "object") return null;
240
+ const data = incoming;
241
+ if (data.type !== "openkey:resize") return null;
242
+ if (expected.requestId === null || expected.protocolVersion === null) return null;
243
+ if (typeof data.protocolVersion !== "number" || data.protocolVersion !== expected.protocolVersion) {
244
+ return null;
245
+ }
246
+ if (typeof data.requestId !== "string" || data.requestId !== expected.requestId) {
247
+ return null;
248
+ }
249
+ if (typeof data.height !== "number" || !Number.isFinite(data.height) || data.height <= 0) {
250
+ return null;
251
+ }
252
+ return Math.min(data.height, Math.floor(expected.viewportHeight * 0.85));
253
+ }
254
+ function shouldRouteAuthorizeTinyCloudExternal(state) {
255
+ return state.lastAuth?.keyType === "EXTERNAL";
256
+ }
257
+ var KeyIdTypeMismatchError = class extends Error {
258
+ code;
259
+ requestKeyId;
260
+ lastAuthKeyId;
261
+ requestKeyType;
262
+ lastAuthKeyType;
263
+ constructor(fields) {
264
+ super(fields.message);
265
+ this.name = "KeyIdTypeMismatchError";
266
+ this.code = "KEY_ID_TYPE_MISMATCH";
267
+ this.requestKeyId = fields.requestKeyId;
268
+ this.lastAuthKeyId = fields.lastAuthKeyId;
269
+ this.requestKeyType = fields.requestKeyType;
270
+ this.lastAuthKeyType = fields.lastAuthKeyType;
271
+ }
272
+ };
273
+ function resolveAuthorizeTinyCloudRouting(state) {
274
+ const lastAuthKeyId = state.lastAuth?.keyId ?? null;
275
+ const lastAuthKeyType = state.lastAuth?.keyType ?? null;
276
+ const rawRequestKeyId = state.requestKeyId ?? null;
277
+ const requestKeyId = rawRequestKeyId && rawRequestKeyId.length > 0 ? rawRequestKeyId : null;
278
+ const knownKeys = state.knownKeys ?? [];
279
+ const lookupType = (id) => {
280
+ for (const k2 of knownKeys) {
281
+ if (k2.id === id) return k2.keyType;
282
+ if (k2.address && k2.address.toLowerCase() === id.toLowerCase()) return k2.keyType;
283
+ }
284
+ if (id.startsWith("external:")) return "EXTERNAL";
285
+ return null;
286
+ };
287
+ if (!requestKeyId) {
288
+ const route = lastAuthKeyType === "EXTERNAL" ? "external" : "managed";
289
+ return {
290
+ route,
291
+ resolvedKeyId: lastAuthKeyId,
292
+ resolvedKeyType: lastAuthKeyType
293
+ };
294
+ }
295
+ let requestKeyType = lookupType(requestKeyId);
296
+ if (!requestKeyType && lastAuthKeyId && requestKeyId === lastAuthKeyId) {
297
+ requestKeyType = lastAuthKeyType;
298
+ }
299
+ if (requestKeyType && lastAuthKeyType && requestKeyType !== lastAuthKeyType) {
300
+ throw new KeyIdTypeMismatchError({
301
+ message: `authorizeTinyCloud: request.keyId ${requestKeyId} resolves to ${requestKeyType} but active session key ${lastAuthKeyId} is ${lastAuthKeyType}; refuse to route silently`,
302
+ requestKeyId,
303
+ lastAuthKeyId,
304
+ requestKeyType,
305
+ lastAuthKeyType
306
+ });
307
+ }
308
+ const effectiveType = requestKeyType ?? lastAuthKeyType;
309
+ return {
310
+ route: effectiveType === "EXTERNAL" ? "external" : "managed",
311
+ resolvedKeyId: requestKeyId,
312
+ resolvedKeyType: effectiveType
313
+ };
314
+ }
14
315
  var IframeModal = class {
15
316
  root;
16
317
  shadow;
@@ -19,6 +320,13 @@ var IframeModal = class {
19
320
  onMessage;
20
321
  messageHandler;
21
322
  host;
323
+ // Sol final continuation contract requirement 5: iframe resize traffic
324
+ // MUST correlate to the active request's requestId AND protocolVersion.
325
+ // Set by `setExpectedCorrelation` after the outer flow decides which
326
+ // request this modal is bound to. When both are null, resize messages
327
+ // are dropped (no active request → no resize authority).
328
+ expectedRequestId = null;
329
+ expectedProtocolVersion = null;
22
330
  constructor(opts) {
23
331
  this.host = opts.host;
24
332
  this.onClose = opts.onClose;
@@ -74,10 +382,17 @@ var IframeModal = class {
74
382
  if (event.source !== this.iframe.contentWindow) return;
75
383
  const data = event.data;
76
384
  if (data.type === "openkey:resize") {
77
- const h = Math.min(data.height, Math.floor(window.innerHeight * 0.85));
385
+ const h = validateIframeResize(event.data, {
386
+ requestId: this.expectedRequestId,
387
+ protocolVersion: this.expectedProtocolVersion,
388
+ viewportHeight: window.innerHeight
389
+ });
390
+ if (h === null) return;
78
391
  this.iframe.style.height = `${h}px`;
79
392
  return;
80
393
  }
394
+ if (data.type === "openkey:close") {
395
+ }
81
396
  this.onMessage(data);
82
397
  };
83
398
  window.addEventListener("message", this.messageHandler);
@@ -86,6 +401,20 @@ var IframeModal = class {
86
401
  postMessage(message) {
87
402
  this.iframe.contentWindow?.postMessage(message, this.host);
88
403
  }
404
+ /**
405
+ * Sol final continuation contract requirement 5: bind the active
406
+ * request correlation so subsequent resize (and future correlated)
407
+ * messages can be verified against `requestId` + `protocolVersion`.
408
+ *
409
+ * The outer flow calls this immediately BEFORE posting the sign
410
+ * request into the iframe, so a resize that arrives synchronously
411
+ * after ready is correlated correctly. Passing `null` for either
412
+ * argument disables correlation (resize is dropped).
413
+ */
414
+ setExpectedCorrelation(requestId, protocolVersion) {
415
+ this.expectedRequestId = requestId;
416
+ this.expectedProtocolVersion = protocolVersion;
417
+ }
89
418
  destroy() {
90
419
  window.removeEventListener("message", this.messageHandler);
91
420
  this.root.remove();
@@ -219,12 +548,23 @@ var OpenKey = class {
219
548
  lastAuth = null;
220
549
  discoveredProviders = [];
221
550
  sessionToken = null;
551
+ /** Cancels widget flows that could outlive a sign-out on this instance. */
552
+ activeFlowCancellations = /* @__PURE__ */ new Set();
553
+ /**
554
+ * Nostr identity custody + signing (secp256k1 Schnorr / BIP-340). Kept
555
+ * fully separate from the Ethereum flows above: it never reuses
556
+ * `lastAuth`/`sessionToken`, and its widget messages never carry a
557
+ * sessionToken - the OpenKey session and any signing grants stay inside
558
+ * the OpenKey-origin iframe for the whole lifetime of this client.
559
+ */
560
+ nostr;
222
561
  constructor(config = {}) {
223
562
  this.config = config;
224
563
  this.host = config.host || DEFAULT_HOST;
225
564
  this.oauthHost = config.oauthHost || this.deriveOAuthHost(this.host);
226
565
  this.appName = config.appName || window.location.hostname;
227
566
  this.mode = config.mode ?? "iframe";
567
+ this.nostr = new OpenKeyNostr(this.host);
228
568
  if (typeof window !== "undefined") {
229
569
  this.discoveredProviders = [];
230
570
  window.addEventListener("eip6963:announceProvider", (event) => {
@@ -250,6 +590,37 @@ var OpenKey = class {
250
590
  return "https://api.openkey.so";
251
591
  }
252
592
  }
593
+ /**
594
+ * Sol MAJOR-4 (final continuation): fetch the caller's registered keys
595
+ * so `resolveAuthorizeTinyCloudRouting` can resolve an explicit pin
596
+ * whose keyId does not match the active session key. Called only when
597
+ * the caller passes a `requestKeyId` we do not already recognise; the
598
+ * common no-pin path skips the network round-trip.
599
+ *
600
+ * Failures do NOT block routing — we fall through with `knownKeys=[]`
601
+ * so the resolver's lastAuth-based fallback still runs. If the caller
602
+ * pinned a genuinely unknown key that turns out to be external, the
603
+ * widget rejects the server-signing path and the SDK surfaces that
604
+ * error from the finalize call rather than silently routing wrong.
605
+ */
606
+ async fetchKnownKeysIfNeeded(requestKeyId) {
607
+ if (!requestKeyId) return [];
608
+ const la = this.lastAuth;
609
+ if (la?.keyId === requestKeyId) return [];
610
+ if (la?.address && la.address.toLowerCase() === requestKeyId.toLowerCase()) return [];
611
+ if (requestKeyId.startsWith("external:")) return [];
612
+ try {
613
+ const res = await fetch(`${this.oauthHost}/api/keys`, {
614
+ method: "GET",
615
+ credentials: "include"
616
+ });
617
+ if (!res.ok) return [];
618
+ const body = await res.json();
619
+ return Array.isArray(body?.keys) ? body.keys : [];
620
+ } catch {
621
+ return [];
622
+ }
623
+ }
253
624
  /**
254
625
  * Connect to OpenKey and get user's wallet address
255
626
  * Opens auth flow for user to select/create a key
@@ -262,6 +633,36 @@ var OpenKey = class {
262
633
  this.lastAuth = result;
263
634
  return result;
264
635
  }
636
+ /**
637
+ * Signs the active OpenKey account out through the OpenKey-owned widget.
638
+ *
639
+ * The SDK passes its Better Auth bearer through the OpenKey-owned widget
640
+ * protocol so applications do not have to implement revocation themselves.
641
+ * Local SDK session state is cleared before opening the widget, including
642
+ * when remote revocation is unavailable.
643
+ */
644
+ async signOut(opts) {
645
+ const requestId = `ok-signout-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
646
+ const sessionToken = this.sessionToken || void 0;
647
+ const mode = this.resolveMode(opts?.mode) === "redirect" ? "popup" : opts?.mode;
648
+ this.cancelActiveFlows();
649
+ this.lastAuth = null;
650
+ this.sessionToken = null;
651
+ const acknowledgement = await this.openFlow(
652
+ "sign-out",
653
+ {
654
+ type: "openkey:sign-out:request",
655
+ requestId,
656
+ protocolVersion: 1,
657
+ sessionToken
658
+ },
659
+ mode
660
+ );
661
+ if (acknowledgement.requestId !== requestId) {
662
+ throw new Error("OpenKey signOut received an acknowledgement for a different request");
663
+ }
664
+ return acknowledgement;
665
+ }
265
666
  /**
266
667
  * Better-auth session token relayed during connect(), or null if the
267
668
  * flow did not provide one. Use as a bearer token for API calls made
@@ -300,6 +701,208 @@ var OpenKey = class {
300
701
  }
301
702
  return this.signWithOpenKey(request, opts?.mode);
302
703
  }
704
+ /**
705
+ * Versioned TinyCloud authorization protocol (v1).
706
+ *
707
+ * Unlike `signMessage()` (which is contractually byte-exact — the returned
708
+ * signature always verifies against the caller's exact bytes), this method
709
+ * permits the user to narrow the requested capabilities before signing.
710
+ * OpenKey may therefore regenerate the SIWE. The response carries
711
+ * `signedMessage` — the exact bytes the signature actually verifies against.
712
+ *
713
+ * TinyCloud completes the session with `signedMessage`, not the original
714
+ * `siwe` passed in. Consumers MUST use the returned bytes and MUST NOT
715
+ * assume the returned grants are a superset of what they requested — the
716
+ * user is allowed to remove any grant that is not required.
717
+ *
718
+ * Legacy `signMessage()` callers are unaffected: their exact-byte contract
719
+ * remains preserved and OpenKey never rewrites their bytes.
720
+ */
721
+ async authorizeTinyCloud(request, opts) {
722
+ if (request.protocolVersion !== 1) {
723
+ throw new Error(
724
+ `authorizeTinyCloud only supports protocolVersion 1; got ${request.protocolVersion}`
725
+ );
726
+ }
727
+ if (typeof request.siwe !== "string" || !request.siwe) {
728
+ throw new Error("authorizeTinyCloud requires a non-empty SIWE");
729
+ }
730
+ let routing;
731
+ try {
732
+ const knownKeys = await this.fetchKnownKeysIfNeeded(request.keyId);
733
+ routing = resolveAuthorizeTinyCloudRouting({
734
+ lastAuth: this.lastAuth,
735
+ requestKeyId: request.keyId,
736
+ knownKeys
737
+ });
738
+ } catch (e) {
739
+ if (e instanceof KeyIdTypeMismatchError) throw e;
740
+ throw e instanceof Error ? e : new Error("authorizeTinyCloud: failed to resolve target key type");
741
+ }
742
+ if (routing.route === "external") {
743
+ return this.authorizeTinyCloudExternal(request, opts);
744
+ }
745
+ const requestId = `ok-auth-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
746
+ const resolvedKeyId = routing.resolvedKeyId ?? request.keyId ?? this.lastAuth?.keyId;
747
+ const raw = await this.openFlow(
748
+ "sign",
749
+ {
750
+ type: "openkey:sign:request",
751
+ requestId,
752
+ protocolVersion: 1,
753
+ message: request.siwe,
754
+ keyId: resolvedKeyId,
755
+ // Forward JWK so the widget can pass it to /authorize-sign for
756
+ // narrowed-SIWE regeneration.
757
+ jwk: request.jwk,
758
+ // Forward the TinyCloud host so the widget can bind it into the
759
+ // /authorize-sign-prepare context (Sol MAJOR-2).
760
+ host: request.host,
761
+ // Sol MAJOR-2 (envelope): forward the caller-supplied presentation
762
+ // envelope so the widget can render honest provenance and the
763
+ // /authorize-sign-prepare route can bind it into the authorization
764
+ // context. The envelope is display-only; the widget transport
765
+ // validates and size-bounds it before use and NEVER treats it as
766
+ // verified unless the server actually origin-binds a manifest.
767
+ presentation: request.presentation,
768
+ sessionToken: this.sessionToken || void 0
769
+ },
770
+ opts?.mode
771
+ );
772
+ if (typeof raw.signedMessage !== "string" || !raw.signedMessage) {
773
+ throw new Error(
774
+ "authorizeTinyCloud: widget returned no signedMessage \u2014 signature would not correspond to the displayed authorization"
775
+ );
776
+ }
777
+ if (!Array.isArray(raw.selectedActionKeys)) {
778
+ throw new Error(
779
+ "authorizeTinyCloud: widget returned no selectedActionKeys \u2014 cannot verify displayed authorization matches the signed bytes"
780
+ );
781
+ }
782
+ if (!Array.isArray(raw.permissions)) {
783
+ throw new Error(
784
+ "authorizeTinyCloud: widget returned no permissions \u2014 cannot verify displayed authorization matches the signed bytes"
785
+ );
786
+ }
787
+ return {
788
+ protocolVersion: 1,
789
+ address: raw.address,
790
+ signature: raw.signature,
791
+ signedMessage: raw.signedMessage,
792
+ selectedActionKeys: raw.selectedActionKeys,
793
+ permissions: raw.permissions
794
+ };
795
+ }
796
+ /**
797
+ * Sol MAJOR-3 (continuation): authorizeTinyCloud path for external keys.
798
+ * The wallet lives outside OpenKey, so the SDK opens the shared widget
799
+ * for review + preview, then invokes the user's wallet to sign the
800
+ * preview bytes, then finalizes server-side via /authorize-sign.
801
+ *
802
+ * Flow:
803
+ * 1. Open the widget with `externalSign: true` — the widget renders
804
+ * the shared SigningApproval UI, calls /authorize-sign-prepare,
805
+ * calls /authorize-sign-preview after the user approves a
806
+ * selection, and hands back `{ previewApprovalToken,
807
+ * signedMessage, selectedActionIds, address, authorizationContextToken }`.
808
+ * 2. SDK invokes the user's wallet on the returned signedMessage.
809
+ * 3. SDK POSTs /authorize-sign with `externalSignature`.
810
+ *
811
+ * Legacy exact-byte signMessage callers are unaffected — this path is
812
+ * only taken via authorizeTinyCloud (protocolVersion:1) when the target
813
+ * key is EXTERNAL. Sol's rejection required this path to go through
814
+ * the same review UI as managed keys.
815
+ */
816
+ async authorizeTinyCloudExternal(request, opts) {
817
+ const keyId = request.keyId ?? this.lastAuth?.keyId;
818
+ if (!keyId) {
819
+ throw new Error(
820
+ "authorizeTinyCloud (external) requires a keyId \u2014 call connect() first or pass request.keyId"
821
+ );
822
+ }
823
+ const walletAddress = this.lastAuth?.address;
824
+ if (!walletAddress) {
825
+ throw new Error(
826
+ "authorizeTinyCloud (external) requires a connected wallet \u2014 call connect() first"
827
+ );
828
+ }
829
+ const requestId = `ok-ext-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
830
+ const approval = await this.openFlow(
831
+ "sign",
832
+ {
833
+ type: "openkey:sign:request",
834
+ requestId,
835
+ protocolVersion: 1,
836
+ message: request.siwe,
837
+ keyId,
838
+ jwk: request.jwk,
839
+ host: request.host,
840
+ // Sol MAJOR-2 (envelope): forward the caller-supplied presentation
841
+ // envelope. Same rules as the managed path — display-only, size-
842
+ // bounded at the transport, never treated as verified without
843
+ // server-side origin-binding.
844
+ presentation: request.presentation,
845
+ sessionToken: this.sessionToken || void 0,
846
+ // Sol MAJOR-3 (continuation): tell the widget to hand us back
847
+ // the previewApproval instead of asking OpenKey to sign
848
+ // server-side with a managed key. The widget will still render
849
+ // the SAME SigningApproval component; it just diverges at the
850
+ // "sign" step and emits `openkey:externalSign:approve` back
851
+ // through the transport.
852
+ externalSign: true
853
+ },
854
+ opts?.mode
855
+ );
856
+ if (!approval?.previewApprovalToken || !approval?.signedMessage) {
857
+ throw new Error(
858
+ "authorizeTinyCloud (external): widget did not return a previewApprovalToken + signedMessage"
859
+ );
860
+ }
861
+ if (approval.address && approval.address.toLowerCase() !== walletAddress.toLowerCase()) {
862
+ throw new Error(
863
+ `authorizeTinyCloud (external): widget preview address ${approval.address} does not match connected wallet ${walletAddress}`
864
+ );
865
+ }
866
+ const provider = await this.findWalletProvider(walletAddress);
867
+ const hexMessage = this.toHex(approval.signedMessage);
868
+ const walletSignature = await provider.request({
869
+ method: "personal_sign",
870
+ params: [hexMessage, walletAddress]
871
+ });
872
+ const finalizeRes = await fetch(
873
+ `${this.oauthHost}/api/delegate/authorize-sign`,
874
+ {
875
+ method: "POST",
876
+ credentials: "include",
877
+ headers: { "Content-Type": "application/json" },
878
+ body: JSON.stringify({
879
+ authorizationContextToken: approval.authorizationContextToken,
880
+ previewApprovalToken: approval.previewApprovalToken,
881
+ selectedActionIds: approval.selectedActionIds,
882
+ protocolVersion: 1,
883
+ externalSignature: walletSignature
884
+ })
885
+ }
886
+ );
887
+ if (!finalizeRes.ok) {
888
+ const body = await finalizeRes.json().catch(() => ({ error: `HTTP ${finalizeRes.status}` }));
889
+ throw new Error(body.error || `authorize-sign failed (${finalizeRes.status})`);
890
+ }
891
+ const finalize = await finalizeRes.json();
892
+ if (finalize.signedMessage !== approval.signedMessage) {
893
+ throw new Error(
894
+ "authorizeTinyCloud (external): server signed different bytes than the preview showed"
895
+ );
896
+ }
897
+ return {
898
+ protocolVersion: 1,
899
+ address: finalize.address,
900
+ signature: finalize.signature,
901
+ signedMessage: finalize.signedMessage,
902
+ selectedActionKeys: finalize.selectedActionKeys ?? [],
903
+ permissions: finalize.permissions ?? []
904
+ };
905
+ }
303
906
  /**
304
907
  * Sign typed data (EIP-712) with the user's OpenKey wallet
305
908
  * For external keys, routes directly to the user's wallet provider
@@ -434,14 +1037,14 @@ var OpenKey = class {
434
1037
  * @returns Promise that resolves with authorization code after user consent
435
1038
  */
436
1039
  connect: async (config) => {
437
- const verifier = generateCodeVerifier();
438
- const challenge = await generateCodeChallenge(verifier);
439
- const state = config.state || generateState();
1040
+ const verifier = y();
1041
+ const challenge = await k(verifier);
1042
+ const state = config.state || m();
440
1043
  sessionStorage.setItem(
441
1044
  OAUTH_STORAGE_KEY,
442
1045
  JSON.stringify({ verifier, state })
443
1046
  );
444
- const authUrl = buildAuthorizationUrl({
1047
+ const authUrl = T({
445
1048
  host: this.oauthHost,
446
1049
  clientId: config.clientId,
447
1050
  redirectUri: config.redirectUri,
@@ -613,7 +1216,7 @@ var OpenKey = class {
613
1216
  const eoaFlag = action === "connect" && this.hasDetectedEoa() ? "&hasEoa=true" : "";
614
1217
  if (mode === "popup") {
615
1218
  const url2 = `${this.host}/widget/${action}?origin=${origin}${eoaFlag}`;
616
- return new Promise((resolve, reject) => this.openPopup(url2, message, resolve, reject));
1219
+ return new Promise((resolve, reject) => this.openPopup(action, url2, message, resolve, reject));
617
1220
  }
618
1221
  if (mode === "redirect") {
619
1222
  const url2 = `${this.host}/widget/${action}?origin=${origin}${eoaFlag}`;
@@ -625,10 +1228,14 @@ var OpenKey = class {
625
1228
  return this.openIframeModal(url, action, message, origin);
626
1229
  }
627
1230
  openIframeModal(url, action, message, origin) {
1231
+ const outgoingRequestId = message.requestId;
1232
+ const outgoingProtocolVersion = message.protocolVersion;
1233
+ const isVersionedRequest = typeof outgoingRequestId === "string" && typeof outgoingProtocolVersion === "number" && outgoingProtocolVersion >= 1;
628
1234
  return new Promise((resolve, reject) => {
629
1235
  let readyReceived = false;
630
1236
  let settled = false;
631
1237
  let modal = null;
1238
+ let cancel;
632
1239
  const settle = (fn) => {
633
1240
  if (settled) return;
634
1241
  settled = true;
@@ -639,24 +1246,35 @@ var OpenKey = class {
639
1246
  clearTimeout(overallTimeout);
640
1247
  modal?.destroy();
641
1248
  modal = null;
1249
+ this.activeFlowCancellations.delete(cancel);
642
1250
  };
643
1251
  modal = new IframeModal({
644
1252
  url,
645
1253
  host: this.host,
646
1254
  onClose: () => {
647
1255
  settle(() => {
648
- clearTimeout(readyTimeout);
649
- clearTimeout(overallTimeout);
650
- modal = null;
1256
+ cleanup();
651
1257
  reject({ code: "USER_CANCELLED", message: "User cancelled the request" });
652
1258
  });
653
1259
  },
654
1260
  onMessage: (data) => {
655
1261
  if (data.type === "openkey:ready") {
656
1262
  readyReceived = true;
1263
+ if (isVersionedRequest && modal) {
1264
+ modal.setExpectedCorrelation(
1265
+ outgoingRequestId,
1266
+ outgoingProtocolVersion
1267
+ );
1268
+ }
657
1269
  modal?.postMessage(message);
658
1270
  return;
659
1271
  }
1272
+ if (isVersionedRequest) {
1273
+ const respRequestId = data.requestId;
1274
+ const respVersion = data.protocolVersion;
1275
+ if (respRequestId !== outgoingRequestId) return;
1276
+ if (respVersion !== outgoingProtocolVersion) return;
1277
+ }
660
1278
  if (data.type === "openkey:close") {
661
1279
  settle(() => {
662
1280
  cleanup();
@@ -673,7 +1291,7 @@ var OpenKey = class {
673
1291
  this.handleExternalWalletConnect(resolve, reject);
674
1292
  return;
675
1293
  }
676
- if (data.type === "openkey:auth:response" || data.type === "openkey:sign:response" || data.type === "openkey:signTypedData:response" || data.type === "openkey:link-wallet:response") {
1294
+ if (this.isResponseForAction(action, data)) {
677
1295
  settle(() => {
678
1296
  cleanup();
679
1297
  if (data.success) {
@@ -682,8 +1300,24 @@ var OpenKey = class {
682
1300
  resolve({ address: data.address, keyId: data.keyId, keyType: data.keyType || "MANAGED" });
683
1301
  } else if (data.type === "openkey:link-wallet:response") {
684
1302
  resolve({ address: data.address, keyId: data.keyId });
1303
+ } else if (data.type === "openkey:sign-out:response") {
1304
+ resolve({ requestId: data.requestId, revoked: data.revoked });
1305
+ } else if (data.type === "openkey:externalSign:approve") {
1306
+ resolve({
1307
+ authorizationContextToken: data.authorizationContextToken,
1308
+ previewApprovalToken: data.previewApprovalToken,
1309
+ signedMessage: data.signedMessage,
1310
+ selectedActionIds: data.selectedActionIds,
1311
+ address: data.address
1312
+ });
685
1313
  } else {
686
- resolve({ signature: data.signature, address: data.address });
1314
+ resolve({
1315
+ signature: data.signature,
1316
+ address: data.address,
1317
+ signedMessage: data.signedMessage,
1318
+ selectedActionKeys: data.selectedActionKeys,
1319
+ permissions: data.permissions
1320
+ });
687
1321
  }
688
1322
  } else {
689
1323
  reject(data.error);
@@ -698,7 +1332,7 @@ var OpenKey = class {
698
1332
  console.warn("OpenKey: iframe blocked by CSP, falling back to popup. Add frame-src https://openkey.so to your CSP.");
699
1333
  showToast();
700
1334
  const popupUrl = `${this.host}/widget/${action}?origin=${origin}`;
701
- this.openPopup(popupUrl, message, (val) => settle(() => resolve(val)), (err) => settle(() => reject(err)));
1335
+ this.openPopup(action, popupUrl, message, (val) => settle(() => resolve(val)), (err) => settle(() => reject(err)));
702
1336
  }, IFRAME_READY_TIMEOUT);
703
1337
  const overallTimeout = setTimeout(() => {
704
1338
  settle(() => {
@@ -706,8 +1340,24 @@ var OpenKey = class {
706
1340
  reject({ code: "TIMEOUT", message: "Request timed out" });
707
1341
  });
708
1342
  }, DEFAULT_TIMEOUT);
1343
+ cancel = () => {
1344
+ settle(() => {
1345
+ cleanup();
1346
+ reject({ code: "USER_CANCELLED", message: "Superseded by sign-out" });
1347
+ });
1348
+ };
1349
+ this.activeFlowCancellations.add(cancel);
709
1350
  });
710
1351
  }
1352
+ isResponseForAction(action, data) {
1353
+ const type = data.type;
1354
+ if (action === "connect") return type === "openkey:auth:response";
1355
+ if (action === "sign") return type === "openkey:sign:response" || type === "openkey:externalSign:approve";
1356
+ if (action === "sign-typed-data") return type === "openkey:signTypedData:response";
1357
+ if (action === "link-wallet") return type === "openkey:link-wallet:response";
1358
+ if (action === "sign-out") return type === "openkey:sign-out:response";
1359
+ return false;
1360
+ }
711
1361
  handleWalletLinkDelegation(modal) {
712
1362
  const sendResult = (result) => modal.postMessage(result);
713
1363
  new WalletPicker({
@@ -769,15 +1419,16 @@ var OpenKey = class {
769
1419
  }
770
1420
  });
771
1421
  }
772
- openPopup(url, message, resolve, reject) {
1422
+ openPopup(action, url, message, resolve, reject) {
773
1423
  const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2;
774
1424
  const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2;
775
- this.popup = window.open(
1425
+ const popup = window.open(
776
1426
  url,
777
- "openkey",
1427
+ `openkey-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
778
1428
  `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},popup=true`
779
1429
  );
780
- if (!this.popup) {
1430
+ this.popup = popup;
1431
+ if (!popup) {
781
1432
  showToast("Popup was blocked. Please allow popups for this site.", "error");
782
1433
  reject({
783
1434
  code: "POPUP_BLOCKED",
@@ -785,74 +1436,129 @@ var OpenKey = class {
785
1436
  });
786
1437
  return;
787
1438
  }
1439
+ let settled = false;
1440
+ let cancel;
788
1441
  const cleanup = () => {
789
1442
  window.removeEventListener("message", handleMessage);
790
1443
  clearInterval(pollClosed);
791
1444
  clearTimeout(timeout);
1445
+ this.activeFlowCancellations.delete(cancel);
1446
+ if (this.popup === popup) this.popup = null;
1447
+ };
1448
+ const settle = (fn) => {
1449
+ if (settled) return;
1450
+ settled = true;
1451
+ cleanup();
1452
+ fn();
792
1453
  };
793
1454
  const pollClosed = setInterval(() => {
794
- if (this.popup?.closed) {
795
- cleanup();
796
- reject({
1455
+ if (popup.closed) {
1456
+ settle(() => reject({
797
1457
  code: "USER_CANCELLED",
798
1458
  message: "User closed the popup"
799
- });
1459
+ }));
800
1460
  }
801
1461
  }, 500);
802
1462
  const timeout = setTimeout(() => {
803
- cleanup();
804
- this.popup?.close();
805
- reject({
806
- code: "TIMEOUT",
807
- message: "Request timed out"
1463
+ settle(() => {
1464
+ popup.close();
1465
+ reject({
1466
+ code: "TIMEOUT",
1467
+ message: "Request timed out"
1468
+ });
808
1469
  });
809
1470
  }, DEFAULT_TIMEOUT);
1471
+ const outgoingRequestId = message.requestId;
1472
+ const outgoingProtocolVersion = message.protocolVersion;
1473
+ const isVersionedRequest = typeof outgoingRequestId === "string" && typeof outgoingProtocolVersion === "number" && outgoingProtocolVersion >= 1;
810
1474
  const handleMessage = (event) => {
811
1475
  if (event.origin !== this.host) return;
812
- if (event.source !== this.popup) return;
1476
+ if (event.source !== popup) return;
813
1477
  const data = event.data;
814
1478
  if (data.type === "openkey:ready") {
815
- this.popup?.postMessage(message, this.host);
1479
+ popup.postMessage(message, this.host);
816
1480
  return;
817
1481
  }
818
1482
  if (data.type === "openkey:close") {
819
- cleanup();
820
- this.popup?.close();
821
- reject({
822
- code: "USER_CANCELLED",
823
- message: "User cancelled the request"
1483
+ if (isVersionedRequest) {
1484
+ const closeRequestId = data.requestId;
1485
+ const closeVersion = data.protocolVersion;
1486
+ if (closeRequestId !== outgoingRequestId) return;
1487
+ if (closeVersion !== outgoingProtocolVersion) return;
1488
+ }
1489
+ settle(() => {
1490
+ popup.close();
1491
+ reject({
1492
+ code: "USER_CANCELLED",
1493
+ message: "User cancelled the request"
1494
+ });
824
1495
  });
825
1496
  return;
826
1497
  }
827
1498
  if (data.type === "openkey:auth:use-external-wallet") {
828
- cleanup();
829
- this.popup?.close();
830
- this.handleExternalWalletConnect(resolve, reject);
1499
+ settle(() => {
1500
+ popup.close();
1501
+ this.handleExternalWalletConnect(resolve, reject);
1502
+ });
831
1503
  return;
832
1504
  }
833
- if (data.type === "openkey:auth:response" || data.type === "openkey:sign:response" || data.type === "openkey:signTypedData:response" || data.type === "openkey:link-wallet:response") {
834
- cleanup();
835
- this.popup?.close();
836
- if (data.success) {
837
- if (data.type === "openkey:auth:response") {
838
- if (data.sessionToken) this.sessionToken = data.sessionToken;
839
- resolve({ address: data.address, keyId: data.keyId, keyType: data.keyType || "MANAGED" });
840
- } else if (data.type === "openkey:link-wallet:response") {
841
- resolve({ address: data.address, keyId: data.keyId });
1505
+ if (this.isResponseForAction(action, data)) {
1506
+ if (isVersionedRequest) {
1507
+ const respRequestId = data.requestId;
1508
+ const respVersion = data.protocolVersion;
1509
+ if (respRequestId !== outgoingRequestId) return;
1510
+ if (respVersion !== outgoingProtocolVersion) return;
1511
+ }
1512
+ settle(() => {
1513
+ popup.close();
1514
+ if (data.success) {
1515
+ if (data.type === "openkey:auth:response") {
1516
+ if (data.sessionToken) this.sessionToken = data.sessionToken;
1517
+ resolve({ address: data.address, keyId: data.keyId, keyType: data.keyType || "MANAGED" });
1518
+ } else if (data.type === "openkey:link-wallet:response") {
1519
+ resolve({ address: data.address, keyId: data.keyId });
1520
+ } else if (data.type === "openkey:sign-out:response") {
1521
+ resolve({ requestId: data.requestId, revoked: data.revoked });
1522
+ } else if (data.type === "openkey:externalSign:approve") {
1523
+ resolve({
1524
+ authorizationContextToken: data.authorizationContextToken,
1525
+ previewApprovalToken: data.previewApprovalToken,
1526
+ signedMessage: data.signedMessage,
1527
+ selectedActionIds: data.selectedActionIds,
1528
+ address: data.address
1529
+ });
1530
+ } else {
1531
+ resolve({
1532
+ signature: data.signature,
1533
+ address: data.address,
1534
+ signedMessage: data.signedMessage,
1535
+ selectedActionKeys: data.selectedActionKeys,
1536
+ permissions: data.permissions
1537
+ });
1538
+ }
842
1539
  } else {
843
- resolve({ signature: data.signature, address: data.address });
1540
+ reject(data.error);
844
1541
  }
845
- } else {
846
- reject(data.error);
847
- }
1542
+ });
848
1543
  }
849
1544
  };
850
1545
  window.addEventListener("message", handleMessage);
1546
+ cancel = () => {
1547
+ settle(() => {
1548
+ popup.close();
1549
+ reject({ code: "USER_CANCELLED", message: "Superseded by sign-out" });
1550
+ });
1551
+ };
1552
+ this.activeFlowCancellations.add(cancel);
1553
+ }
1554
+ cancelActiveFlows() {
1555
+ for (const cancel of [...this.activeFlowCancellations]) cancel();
851
1556
  }
852
1557
  /**
853
1558
  * Disconnect and close any open flows
854
1559
  */
855
1560
  disconnect() {
1561
+ this.cancelActiveFlows();
856
1562
  this.popup?.close();
857
1563
  this.popup = null;
858
1564
  }
@@ -924,9 +1630,14 @@ function getOpenKey(config) {
924
1630
  return defaultInstance;
925
1631
  }
926
1632
  export {
1633
+ KeyIdTypeMismatchError,
927
1634
  OpenKey,
1635
+ OpenKeyNostr,
928
1636
  OpenKeyProvider,
929
1637
  index_default as default,
930
- getOpenKey
1638
+ getOpenKey,
1639
+ resolveAuthorizeTinyCloudRouting,
1640
+ shouldRouteAuthorizeTinyCloudExternal,
1641
+ validateIframeResize
931
1642
  };
932
1643
  //# sourceMappingURL=index.mjs.map