@openkey/sdk 0.9.0 → 0.10.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/dist/index.mjs CHANGED
@@ -1,16 +1,321 @@
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) {
243
+ if (data.requestId !== void 0) return null;
244
+ } else {
245
+ if (expected.requestId === null || expected.protocolVersion === null) return null;
246
+ if (typeof data.protocolVersion !== "number" || data.protocolVersion !== expected.protocolVersion) {
247
+ return null;
248
+ }
249
+ if (typeof data.requestId !== "string" || data.requestId !== expected.requestId) {
250
+ return null;
251
+ }
252
+ }
253
+ if (typeof data.height !== "number" || !Number.isFinite(data.height) || data.height <= 0) {
254
+ return null;
255
+ }
256
+ return Math.min(data.height, Math.floor(expected.viewportHeight * 0.85));
257
+ }
258
+ function shouldRouteAuthorizeTinyCloudExternal(state) {
259
+ return state.lastAuth?.keyType === "EXTERNAL";
260
+ }
261
+ var KeyIdTypeMismatchError = class extends Error {
262
+ code;
263
+ requestKeyId;
264
+ lastAuthKeyId;
265
+ requestKeyType;
266
+ lastAuthKeyType;
267
+ constructor(fields) {
268
+ super(fields.message);
269
+ this.name = "KeyIdTypeMismatchError";
270
+ this.code = "KEY_ID_TYPE_MISMATCH";
271
+ this.requestKeyId = fields.requestKeyId;
272
+ this.lastAuthKeyId = fields.lastAuthKeyId;
273
+ this.requestKeyType = fields.requestKeyType;
274
+ this.lastAuthKeyType = fields.lastAuthKeyType;
275
+ }
276
+ };
277
+ function resolveAuthorizeTinyCloudRouting(state) {
278
+ const lastAuthKeyId = state.lastAuth?.keyId ?? null;
279
+ const lastAuthKeyType = state.lastAuth?.keyType ?? null;
280
+ const rawRequestKeyId = state.requestKeyId ?? null;
281
+ const requestKeyId = rawRequestKeyId && rawRequestKeyId.length > 0 ? rawRequestKeyId : null;
282
+ const knownKeys = state.knownKeys ?? [];
283
+ const lookupType = (id) => {
284
+ for (const k2 of knownKeys) {
285
+ if (k2.id === id) return k2.keyType;
286
+ if (k2.address && k2.address.toLowerCase() === id.toLowerCase()) return k2.keyType;
287
+ }
288
+ if (id.startsWith("external:")) return "EXTERNAL";
289
+ return null;
290
+ };
291
+ if (!requestKeyId) {
292
+ const route = lastAuthKeyType === "EXTERNAL" ? "external" : "managed";
293
+ return {
294
+ route,
295
+ resolvedKeyId: lastAuthKeyId,
296
+ resolvedKeyType: lastAuthKeyType
297
+ };
298
+ }
299
+ let requestKeyType = lookupType(requestKeyId);
300
+ if (!requestKeyType && lastAuthKeyId && requestKeyId === lastAuthKeyId) {
301
+ requestKeyType = lastAuthKeyType;
302
+ }
303
+ if (requestKeyType && lastAuthKeyType && requestKeyType !== lastAuthKeyType) {
304
+ throw new KeyIdTypeMismatchError({
305
+ message: `authorizeTinyCloud: request.keyId ${requestKeyId} resolves to ${requestKeyType} but active session key ${lastAuthKeyId} is ${lastAuthKeyType}; refuse to route silently`,
306
+ requestKeyId,
307
+ lastAuthKeyId,
308
+ requestKeyType,
309
+ lastAuthKeyType
310
+ });
311
+ }
312
+ const effectiveType = requestKeyType ?? lastAuthKeyType;
313
+ return {
314
+ route: effectiveType === "EXTERNAL" ? "external" : "managed",
315
+ resolvedKeyId: requestKeyId,
316
+ resolvedKeyType: effectiveType
317
+ };
318
+ }
14
319
  var IframeModal = class {
15
320
  root;
16
321
  shadow;
@@ -19,6 +324,13 @@ var IframeModal = class {
19
324
  onMessage;
20
325
  messageHandler;
21
326
  host;
327
+ // Sol final continuation contract requirement 5: when the modal carries a
328
+ // versioned request, iframe resize traffic MUST correlate to its
329
+ // requestId AND protocolVersion. Set by `setExpectedCorrelation` as soon
330
+ // as the modal is created. When both are null the flow is unversioned and
331
+ // only uncorrelated resizes from this iframe are accepted (TC-647).
332
+ expectedRequestId = null;
333
+ expectedProtocolVersion = null;
22
334
  constructor(opts) {
23
335
  this.host = opts.host;
24
336
  this.onClose = opts.onClose;
@@ -74,10 +386,17 @@ var IframeModal = class {
74
386
  if (event.source !== this.iframe.contentWindow) return;
75
387
  const data = event.data;
76
388
  if (data.type === "openkey:resize") {
77
- const h = Math.min(data.height, Math.floor(window.innerHeight * 0.85));
389
+ const h = validateIframeResize(event.data, {
390
+ requestId: this.expectedRequestId,
391
+ protocolVersion: this.expectedProtocolVersion,
392
+ viewportHeight: window.innerHeight
393
+ });
394
+ if (h === null) return;
78
395
  this.iframe.style.height = `${h}px`;
79
396
  return;
80
397
  }
398
+ if (data.type === "openkey:close") {
399
+ }
81
400
  this.onMessage(data);
82
401
  };
83
402
  window.addEventListener("message", this.messageHandler);
@@ -86,6 +405,21 @@ var IframeModal = class {
86
405
  postMessage(message) {
87
406
  this.iframe.contentWindow?.postMessage(message, this.host);
88
407
  }
408
+ /**
409
+ * Sol final continuation contract requirement 5: bind the active
410
+ * request correlation so subsequent resize (and future correlated)
411
+ * messages can be verified against `requestId` + `protocolVersion`.
412
+ *
413
+ * The outer flow calls this right after creating the modal for a
414
+ * versioned request, before the iframe can send anything, so no
415
+ * uncorrelated resize is ever accepted for that request. Passing `null`
416
+ * for both arguments means the flow is unversioned; passing `null` for
417
+ * only one drops every resize.
418
+ */
419
+ setExpectedCorrelation(requestId, protocolVersion) {
420
+ this.expectedRequestId = requestId;
421
+ this.expectedProtocolVersion = protocolVersion;
422
+ }
89
423
  destroy() {
90
424
  window.removeEventListener("message", this.messageHandler);
91
425
  this.root.remove();
@@ -219,12 +553,23 @@ var OpenKey = class {
219
553
  lastAuth = null;
220
554
  discoveredProviders = [];
221
555
  sessionToken = null;
556
+ /** Cancels widget flows that could outlive a sign-out on this instance. */
557
+ activeFlowCancellations = /* @__PURE__ */ new Set();
558
+ /**
559
+ * Nostr identity custody + signing (secp256k1 Schnorr / BIP-340). Kept
560
+ * fully separate from the Ethereum flows above: it never reuses
561
+ * `lastAuth`/`sessionToken`, and its widget messages never carry a
562
+ * sessionToken - the OpenKey session and any signing grants stay inside
563
+ * the OpenKey-origin iframe for the whole lifetime of this client.
564
+ */
565
+ nostr;
222
566
  constructor(config = {}) {
223
567
  this.config = config;
224
568
  this.host = config.host || DEFAULT_HOST;
225
569
  this.oauthHost = config.oauthHost || this.deriveOAuthHost(this.host);
226
570
  this.appName = config.appName || window.location.hostname;
227
571
  this.mode = config.mode ?? "iframe";
572
+ this.nostr = new OpenKeyNostr(this.host);
228
573
  if (typeof window !== "undefined") {
229
574
  this.discoveredProviders = [];
230
575
  window.addEventListener("eip6963:announceProvider", (event) => {
@@ -250,6 +595,37 @@ var OpenKey = class {
250
595
  return "https://api.openkey.so";
251
596
  }
252
597
  }
598
+ /**
599
+ * Sol MAJOR-4 (final continuation): fetch the caller's registered keys
600
+ * so `resolveAuthorizeTinyCloudRouting` can resolve an explicit pin
601
+ * whose keyId does not match the active session key. Called only when
602
+ * the caller passes a `requestKeyId` we do not already recognise; the
603
+ * common no-pin path skips the network round-trip.
604
+ *
605
+ * Failures do NOT block routing — we fall through with `knownKeys=[]`
606
+ * so the resolver's lastAuth-based fallback still runs. If the caller
607
+ * pinned a genuinely unknown key that turns out to be external, the
608
+ * widget rejects the server-signing path and the SDK surfaces that
609
+ * error from the finalize call rather than silently routing wrong.
610
+ */
611
+ async fetchKnownKeysIfNeeded(requestKeyId) {
612
+ if (!requestKeyId) return [];
613
+ const la = this.lastAuth;
614
+ if (la?.keyId === requestKeyId) return [];
615
+ if (la?.address && la.address.toLowerCase() === requestKeyId.toLowerCase()) return [];
616
+ if (requestKeyId.startsWith("external:")) return [];
617
+ try {
618
+ const res = await fetch(`${this.oauthHost}/api/keys`, {
619
+ method: "GET",
620
+ credentials: "include"
621
+ });
622
+ if (!res.ok) return [];
623
+ const body = await res.json();
624
+ return Array.isArray(body?.keys) ? body.keys : [];
625
+ } catch {
626
+ return [];
627
+ }
628
+ }
253
629
  /**
254
630
  * Connect to OpenKey and get user's wallet address
255
631
  * Opens auth flow for user to select/create a key
@@ -262,6 +638,36 @@ var OpenKey = class {
262
638
  this.lastAuth = result;
263
639
  return result;
264
640
  }
641
+ /**
642
+ * Signs the active OpenKey account out through the OpenKey-owned widget.
643
+ *
644
+ * The SDK passes its Better Auth bearer through the OpenKey-owned widget
645
+ * protocol so applications do not have to implement revocation themselves.
646
+ * Local SDK session state is cleared before opening the widget, including
647
+ * when remote revocation is unavailable.
648
+ */
649
+ async signOut(opts) {
650
+ const requestId = `ok-signout-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
651
+ const sessionToken = this.sessionToken || void 0;
652
+ const mode = this.resolveMode(opts?.mode) === "redirect" ? "popup" : opts?.mode;
653
+ this.cancelActiveFlows();
654
+ this.lastAuth = null;
655
+ this.sessionToken = null;
656
+ const acknowledgement = await this.openFlow(
657
+ "sign-out",
658
+ {
659
+ type: "openkey:sign-out:request",
660
+ requestId,
661
+ protocolVersion: 1,
662
+ sessionToken
663
+ },
664
+ mode
665
+ );
666
+ if (acknowledgement.requestId !== requestId) {
667
+ throw new Error("OpenKey signOut received an acknowledgement for a different request");
668
+ }
669
+ return acknowledgement;
670
+ }
265
671
  /**
266
672
  * Better-auth session token relayed during connect(), or null if the
267
673
  * flow did not provide one. Use as a bearer token for API calls made
@@ -300,6 +706,208 @@ var OpenKey = class {
300
706
  }
301
707
  return this.signWithOpenKey(request, opts?.mode);
302
708
  }
709
+ /**
710
+ * Versioned TinyCloud authorization protocol (v1).
711
+ *
712
+ * Unlike `signMessage()` (which is contractually byte-exact — the returned
713
+ * signature always verifies against the caller's exact bytes), this method
714
+ * permits the user to narrow the requested capabilities before signing.
715
+ * OpenKey may therefore regenerate the SIWE. The response carries
716
+ * `signedMessage` — the exact bytes the signature actually verifies against.
717
+ *
718
+ * TinyCloud completes the session with `signedMessage`, not the original
719
+ * `siwe` passed in. Consumers MUST use the returned bytes and MUST NOT
720
+ * assume the returned grants are a superset of what they requested — the
721
+ * user is allowed to remove any grant that is not required.
722
+ *
723
+ * Legacy `signMessage()` callers are unaffected: their exact-byte contract
724
+ * remains preserved and OpenKey never rewrites their bytes.
725
+ */
726
+ async authorizeTinyCloud(request, opts) {
727
+ if (request.protocolVersion !== 1) {
728
+ throw new Error(
729
+ `authorizeTinyCloud only supports protocolVersion 1; got ${request.protocolVersion}`
730
+ );
731
+ }
732
+ if (typeof request.siwe !== "string" || !request.siwe) {
733
+ throw new Error("authorizeTinyCloud requires a non-empty SIWE");
734
+ }
735
+ let routing;
736
+ try {
737
+ const knownKeys = await this.fetchKnownKeysIfNeeded(request.keyId);
738
+ routing = resolveAuthorizeTinyCloudRouting({
739
+ lastAuth: this.lastAuth,
740
+ requestKeyId: request.keyId,
741
+ knownKeys
742
+ });
743
+ } catch (e) {
744
+ if (e instanceof KeyIdTypeMismatchError) throw e;
745
+ throw e instanceof Error ? e : new Error("authorizeTinyCloud: failed to resolve target key type");
746
+ }
747
+ if (routing.route === "external") {
748
+ return this.authorizeTinyCloudExternal(request, opts);
749
+ }
750
+ const requestId = `ok-auth-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
751
+ const resolvedKeyId = routing.resolvedKeyId ?? request.keyId ?? this.lastAuth?.keyId;
752
+ const raw = await this.openFlow(
753
+ "sign",
754
+ {
755
+ type: "openkey:sign:request",
756
+ requestId,
757
+ protocolVersion: 1,
758
+ message: request.siwe,
759
+ keyId: resolvedKeyId,
760
+ // Forward JWK so the widget can pass it to /authorize-sign for
761
+ // narrowed-SIWE regeneration.
762
+ jwk: request.jwk,
763
+ // Forward the TinyCloud host so the widget can bind it into the
764
+ // /authorize-sign-prepare context (Sol MAJOR-2).
765
+ host: request.host,
766
+ // Sol MAJOR-2 (envelope): forward the caller-supplied presentation
767
+ // envelope so the widget can render honest provenance and the
768
+ // /authorize-sign-prepare route can bind it into the authorization
769
+ // context. The envelope is display-only; the widget transport
770
+ // validates and size-bounds it before use and NEVER treats it as
771
+ // verified unless the server actually origin-binds a manifest.
772
+ presentation: request.presentation,
773
+ sessionToken: this.sessionToken || void 0
774
+ },
775
+ opts?.mode
776
+ );
777
+ if (typeof raw.signedMessage !== "string" || !raw.signedMessage) {
778
+ throw new Error(
779
+ "authorizeTinyCloud: widget returned no signedMessage \u2014 signature would not correspond to the displayed authorization"
780
+ );
781
+ }
782
+ if (!Array.isArray(raw.selectedActionKeys)) {
783
+ throw new Error(
784
+ "authorizeTinyCloud: widget returned no selectedActionKeys \u2014 cannot verify displayed authorization matches the signed bytes"
785
+ );
786
+ }
787
+ if (!Array.isArray(raw.permissions)) {
788
+ throw new Error(
789
+ "authorizeTinyCloud: widget returned no permissions \u2014 cannot verify displayed authorization matches the signed bytes"
790
+ );
791
+ }
792
+ return {
793
+ protocolVersion: 1,
794
+ address: raw.address,
795
+ signature: raw.signature,
796
+ signedMessage: raw.signedMessage,
797
+ selectedActionKeys: raw.selectedActionKeys,
798
+ permissions: raw.permissions
799
+ };
800
+ }
801
+ /**
802
+ * Sol MAJOR-3 (continuation): authorizeTinyCloud path for external keys.
803
+ * The wallet lives outside OpenKey, so the SDK opens the shared widget
804
+ * for review + preview, then invokes the user's wallet to sign the
805
+ * preview bytes, then finalizes server-side via /authorize-sign.
806
+ *
807
+ * Flow:
808
+ * 1. Open the widget with `externalSign: true` — the widget renders
809
+ * the shared SigningApproval UI, calls /authorize-sign-prepare,
810
+ * calls /authorize-sign-preview after the user approves a
811
+ * selection, and hands back `{ previewApprovalToken,
812
+ * signedMessage, selectedActionIds, address, authorizationContextToken }`.
813
+ * 2. SDK invokes the user's wallet on the returned signedMessage.
814
+ * 3. SDK POSTs /authorize-sign with `externalSignature`.
815
+ *
816
+ * Legacy exact-byte signMessage callers are unaffected — this path is
817
+ * only taken via authorizeTinyCloud (protocolVersion:1) when the target
818
+ * key is EXTERNAL. Sol's rejection required this path to go through
819
+ * the same review UI as managed keys.
820
+ */
821
+ async authorizeTinyCloudExternal(request, opts) {
822
+ const keyId = request.keyId ?? this.lastAuth?.keyId;
823
+ if (!keyId) {
824
+ throw new Error(
825
+ "authorizeTinyCloud (external) requires a keyId \u2014 call connect() first or pass request.keyId"
826
+ );
827
+ }
828
+ const walletAddress = this.lastAuth?.address;
829
+ if (!walletAddress) {
830
+ throw new Error(
831
+ "authorizeTinyCloud (external) requires a connected wallet \u2014 call connect() first"
832
+ );
833
+ }
834
+ const requestId = `ok-ext-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
835
+ const approval = await this.openFlow(
836
+ "sign",
837
+ {
838
+ type: "openkey:sign:request",
839
+ requestId,
840
+ protocolVersion: 1,
841
+ message: request.siwe,
842
+ keyId,
843
+ jwk: request.jwk,
844
+ host: request.host,
845
+ // Sol MAJOR-2 (envelope): forward the caller-supplied presentation
846
+ // envelope. Same rules as the managed path — display-only, size-
847
+ // bounded at the transport, never treated as verified without
848
+ // server-side origin-binding.
849
+ presentation: request.presentation,
850
+ sessionToken: this.sessionToken || void 0,
851
+ // Sol MAJOR-3 (continuation): tell the widget to hand us back
852
+ // the previewApproval instead of asking OpenKey to sign
853
+ // server-side with a managed key. The widget will still render
854
+ // the SAME SigningApproval component; it just diverges at the
855
+ // "sign" step and emits `openkey:externalSign:approve` back
856
+ // through the transport.
857
+ externalSign: true
858
+ },
859
+ opts?.mode
860
+ );
861
+ if (!approval?.previewApprovalToken || !approval?.signedMessage) {
862
+ throw new Error(
863
+ "authorizeTinyCloud (external): widget did not return a previewApprovalToken + signedMessage"
864
+ );
865
+ }
866
+ if (approval.address && approval.address.toLowerCase() !== walletAddress.toLowerCase()) {
867
+ throw new Error(
868
+ `authorizeTinyCloud (external): widget preview address ${approval.address} does not match connected wallet ${walletAddress}`
869
+ );
870
+ }
871
+ const provider = await this.findWalletProvider(walletAddress);
872
+ const hexMessage = this.toHex(approval.signedMessage);
873
+ const walletSignature = await provider.request({
874
+ method: "personal_sign",
875
+ params: [hexMessage, walletAddress]
876
+ });
877
+ const finalizeRes = await fetch(
878
+ `${this.oauthHost}/api/delegate/authorize-sign`,
879
+ {
880
+ method: "POST",
881
+ credentials: "include",
882
+ headers: { "Content-Type": "application/json" },
883
+ body: JSON.stringify({
884
+ authorizationContextToken: approval.authorizationContextToken,
885
+ previewApprovalToken: approval.previewApprovalToken,
886
+ selectedActionIds: approval.selectedActionIds,
887
+ protocolVersion: 1,
888
+ externalSignature: walletSignature
889
+ })
890
+ }
891
+ );
892
+ if (!finalizeRes.ok) {
893
+ const body = await finalizeRes.json().catch(() => ({ error: `HTTP ${finalizeRes.status}` }));
894
+ throw new Error(body.error || `authorize-sign failed (${finalizeRes.status})`);
895
+ }
896
+ const finalize = await finalizeRes.json();
897
+ if (finalize.signedMessage !== approval.signedMessage) {
898
+ throw new Error(
899
+ "authorizeTinyCloud (external): server signed different bytes than the preview showed"
900
+ );
901
+ }
902
+ return {
903
+ protocolVersion: 1,
904
+ address: finalize.address,
905
+ signature: finalize.signature,
906
+ signedMessage: finalize.signedMessage,
907
+ selectedActionKeys: finalize.selectedActionKeys ?? [],
908
+ permissions: finalize.permissions ?? []
909
+ };
910
+ }
303
911
  /**
304
912
  * Sign typed data (EIP-712) with the user's OpenKey wallet
305
913
  * For external keys, routes directly to the user's wallet provider
@@ -434,14 +1042,14 @@ var OpenKey = class {
434
1042
  * @returns Promise that resolves with authorization code after user consent
435
1043
  */
436
1044
  connect: async (config) => {
437
- const verifier = generateCodeVerifier();
438
- const challenge = await generateCodeChallenge(verifier);
439
- const state = config.state || generateState();
1045
+ const verifier = y();
1046
+ const challenge = await k(verifier);
1047
+ const state = config.state || m();
440
1048
  sessionStorage.setItem(
441
1049
  OAUTH_STORAGE_KEY,
442
1050
  JSON.stringify({ verifier, state })
443
1051
  );
444
- const authUrl = buildAuthorizationUrl({
1052
+ const authUrl = T({
445
1053
  host: this.oauthHost,
446
1054
  clientId: config.clientId,
447
1055
  redirectUri: config.redirectUri,
@@ -613,7 +1221,7 @@ var OpenKey = class {
613
1221
  const eoaFlag = action === "connect" && this.hasDetectedEoa() ? "&hasEoa=true" : "";
614
1222
  if (mode === "popup") {
615
1223
  const url2 = `${this.host}/widget/${action}?origin=${origin}${eoaFlag}`;
616
- return new Promise((resolve, reject) => this.openPopup(url2, message, resolve, reject));
1224
+ return new Promise((resolve, reject) => this.openPopup(action, url2, message, resolve, reject));
617
1225
  }
618
1226
  if (mode === "redirect") {
619
1227
  const url2 = `${this.host}/widget/${action}?origin=${origin}${eoaFlag}`;
@@ -625,10 +1233,14 @@ var OpenKey = class {
625
1233
  return this.openIframeModal(url, action, message, origin);
626
1234
  }
627
1235
  openIframeModal(url, action, message, origin) {
1236
+ const outgoingRequestId = message.requestId;
1237
+ const outgoingProtocolVersion = message.protocolVersion;
1238
+ const isVersionedRequest = typeof outgoingRequestId === "string" && typeof outgoingProtocolVersion === "number" && outgoingProtocolVersion >= 1;
628
1239
  return new Promise((resolve, reject) => {
629
1240
  let readyReceived = false;
630
1241
  let settled = false;
631
1242
  let modal = null;
1243
+ let cancel;
632
1244
  const settle = (fn) => {
633
1245
  if (settled) return;
634
1246
  settled = true;
@@ -639,15 +1251,14 @@ var OpenKey = class {
639
1251
  clearTimeout(overallTimeout);
640
1252
  modal?.destroy();
641
1253
  modal = null;
1254
+ this.activeFlowCancellations.delete(cancel);
642
1255
  };
643
1256
  modal = new IframeModal({
644
1257
  url,
645
1258
  host: this.host,
646
1259
  onClose: () => {
647
1260
  settle(() => {
648
- clearTimeout(readyTimeout);
649
- clearTimeout(overallTimeout);
650
- modal = null;
1261
+ cleanup();
651
1262
  reject({ code: "USER_CANCELLED", message: "User cancelled the request" });
652
1263
  });
653
1264
  },
@@ -657,6 +1268,12 @@ var OpenKey = class {
657
1268
  modal?.postMessage(message);
658
1269
  return;
659
1270
  }
1271
+ if (isVersionedRequest) {
1272
+ const respRequestId = data.requestId;
1273
+ const respVersion = data.protocolVersion;
1274
+ if (respRequestId !== outgoingRequestId) return;
1275
+ if (respVersion !== outgoingProtocolVersion) return;
1276
+ }
660
1277
  if (data.type === "openkey:close") {
661
1278
  settle(() => {
662
1279
  cleanup();
@@ -673,7 +1290,7 @@ var OpenKey = class {
673
1290
  this.handleExternalWalletConnect(resolve, reject);
674
1291
  return;
675
1292
  }
676
- if (data.type === "openkey:auth:response" || data.type === "openkey:sign:response" || data.type === "openkey:signTypedData:response" || data.type === "openkey:link-wallet:response") {
1293
+ if (this.isResponseForAction(action, data)) {
677
1294
  settle(() => {
678
1295
  cleanup();
679
1296
  if (data.success) {
@@ -682,8 +1299,24 @@ var OpenKey = class {
682
1299
  resolve({ address: data.address, keyId: data.keyId, keyType: data.keyType || "MANAGED" });
683
1300
  } else if (data.type === "openkey:link-wallet:response") {
684
1301
  resolve({ address: data.address, keyId: data.keyId });
1302
+ } else if (data.type === "openkey:sign-out:response") {
1303
+ resolve({ requestId: data.requestId, revoked: data.revoked });
1304
+ } else if (data.type === "openkey:externalSign:approve") {
1305
+ resolve({
1306
+ authorizationContextToken: data.authorizationContextToken,
1307
+ previewApprovalToken: data.previewApprovalToken,
1308
+ signedMessage: data.signedMessage,
1309
+ selectedActionIds: data.selectedActionIds,
1310
+ address: data.address
1311
+ });
685
1312
  } else {
686
- resolve({ signature: data.signature, address: data.address });
1313
+ resolve({
1314
+ signature: data.signature,
1315
+ address: data.address,
1316
+ signedMessage: data.signedMessage,
1317
+ selectedActionKeys: data.selectedActionKeys,
1318
+ permissions: data.permissions
1319
+ });
687
1320
  }
688
1321
  } else {
689
1322
  reject(data.error);
@@ -692,13 +1325,19 @@ var OpenKey = class {
692
1325
  }
693
1326
  }
694
1327
  });
1328
+ if (isVersionedRequest) {
1329
+ modal.setExpectedCorrelation(
1330
+ outgoingRequestId,
1331
+ outgoingProtocolVersion
1332
+ );
1333
+ }
695
1334
  const readyTimeout = setTimeout(() => {
696
1335
  if (readyReceived || settled) return;
697
1336
  cleanup();
698
1337
  console.warn("OpenKey: iframe blocked by CSP, falling back to popup. Add frame-src https://openkey.so to your CSP.");
699
1338
  showToast();
700
1339
  const popupUrl = `${this.host}/widget/${action}?origin=${origin}`;
701
- this.openPopup(popupUrl, message, (val) => settle(() => resolve(val)), (err) => settle(() => reject(err)));
1340
+ this.openPopup(action, popupUrl, message, (val) => settle(() => resolve(val)), (err) => settle(() => reject(err)));
702
1341
  }, IFRAME_READY_TIMEOUT);
703
1342
  const overallTimeout = setTimeout(() => {
704
1343
  settle(() => {
@@ -706,8 +1345,24 @@ var OpenKey = class {
706
1345
  reject({ code: "TIMEOUT", message: "Request timed out" });
707
1346
  });
708
1347
  }, DEFAULT_TIMEOUT);
1348
+ cancel = () => {
1349
+ settle(() => {
1350
+ cleanup();
1351
+ reject({ code: "USER_CANCELLED", message: "Superseded by sign-out" });
1352
+ });
1353
+ };
1354
+ this.activeFlowCancellations.add(cancel);
709
1355
  });
710
1356
  }
1357
+ isResponseForAction(action, data) {
1358
+ const type = data.type;
1359
+ if (action === "connect") return type === "openkey:auth:response";
1360
+ if (action === "sign") return type === "openkey:sign:response" || type === "openkey:externalSign:approve";
1361
+ if (action === "sign-typed-data") return type === "openkey:signTypedData:response";
1362
+ if (action === "link-wallet") return type === "openkey:link-wallet:response";
1363
+ if (action === "sign-out") return type === "openkey:sign-out:response";
1364
+ return false;
1365
+ }
711
1366
  handleWalletLinkDelegation(modal) {
712
1367
  const sendResult = (result) => modal.postMessage(result);
713
1368
  new WalletPicker({
@@ -769,15 +1424,16 @@ var OpenKey = class {
769
1424
  }
770
1425
  });
771
1426
  }
772
- openPopup(url, message, resolve, reject) {
1427
+ openPopup(action, url, message, resolve, reject) {
773
1428
  const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2;
774
1429
  const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2;
775
- this.popup = window.open(
1430
+ const popup = window.open(
776
1431
  url,
777
- "openkey",
1432
+ `openkey-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
778
1433
  `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},popup=true`
779
1434
  );
780
- if (!this.popup) {
1435
+ this.popup = popup;
1436
+ if (!popup) {
781
1437
  showToast("Popup was blocked. Please allow popups for this site.", "error");
782
1438
  reject({
783
1439
  code: "POPUP_BLOCKED",
@@ -785,74 +1441,129 @@ var OpenKey = class {
785
1441
  });
786
1442
  return;
787
1443
  }
1444
+ let settled = false;
1445
+ let cancel;
788
1446
  const cleanup = () => {
789
1447
  window.removeEventListener("message", handleMessage);
790
1448
  clearInterval(pollClosed);
791
1449
  clearTimeout(timeout);
1450
+ this.activeFlowCancellations.delete(cancel);
1451
+ if (this.popup === popup) this.popup = null;
1452
+ };
1453
+ const settle = (fn) => {
1454
+ if (settled) return;
1455
+ settled = true;
1456
+ cleanup();
1457
+ fn();
792
1458
  };
793
1459
  const pollClosed = setInterval(() => {
794
- if (this.popup?.closed) {
795
- cleanup();
796
- reject({
1460
+ if (popup.closed) {
1461
+ settle(() => reject({
797
1462
  code: "USER_CANCELLED",
798
1463
  message: "User closed the popup"
799
- });
1464
+ }));
800
1465
  }
801
1466
  }, 500);
802
1467
  const timeout = setTimeout(() => {
803
- cleanup();
804
- this.popup?.close();
805
- reject({
806
- code: "TIMEOUT",
807
- message: "Request timed out"
1468
+ settle(() => {
1469
+ popup.close();
1470
+ reject({
1471
+ code: "TIMEOUT",
1472
+ message: "Request timed out"
1473
+ });
808
1474
  });
809
1475
  }, DEFAULT_TIMEOUT);
1476
+ const outgoingRequestId = message.requestId;
1477
+ const outgoingProtocolVersion = message.protocolVersion;
1478
+ const isVersionedRequest = typeof outgoingRequestId === "string" && typeof outgoingProtocolVersion === "number" && outgoingProtocolVersion >= 1;
810
1479
  const handleMessage = (event) => {
811
1480
  if (event.origin !== this.host) return;
812
- if (event.source !== this.popup) return;
1481
+ if (event.source !== popup) return;
813
1482
  const data = event.data;
814
1483
  if (data.type === "openkey:ready") {
815
- this.popup?.postMessage(message, this.host);
1484
+ popup.postMessage(message, this.host);
816
1485
  return;
817
1486
  }
818
1487
  if (data.type === "openkey:close") {
819
- cleanup();
820
- this.popup?.close();
821
- reject({
822
- code: "USER_CANCELLED",
823
- message: "User cancelled the request"
1488
+ if (isVersionedRequest) {
1489
+ const closeRequestId = data.requestId;
1490
+ const closeVersion = data.protocolVersion;
1491
+ if (closeRequestId !== outgoingRequestId) return;
1492
+ if (closeVersion !== outgoingProtocolVersion) return;
1493
+ }
1494
+ settle(() => {
1495
+ popup.close();
1496
+ reject({
1497
+ code: "USER_CANCELLED",
1498
+ message: "User cancelled the request"
1499
+ });
824
1500
  });
825
1501
  return;
826
1502
  }
827
1503
  if (data.type === "openkey:auth:use-external-wallet") {
828
- cleanup();
829
- this.popup?.close();
830
- this.handleExternalWalletConnect(resolve, reject);
1504
+ settle(() => {
1505
+ popup.close();
1506
+ this.handleExternalWalletConnect(resolve, reject);
1507
+ });
831
1508
  return;
832
1509
  }
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 });
1510
+ if (this.isResponseForAction(action, data)) {
1511
+ if (isVersionedRequest) {
1512
+ const respRequestId = data.requestId;
1513
+ const respVersion = data.protocolVersion;
1514
+ if (respRequestId !== outgoingRequestId) return;
1515
+ if (respVersion !== outgoingProtocolVersion) return;
1516
+ }
1517
+ settle(() => {
1518
+ popup.close();
1519
+ if (data.success) {
1520
+ if (data.type === "openkey:auth:response") {
1521
+ if (data.sessionToken) this.sessionToken = data.sessionToken;
1522
+ resolve({ address: data.address, keyId: data.keyId, keyType: data.keyType || "MANAGED" });
1523
+ } else if (data.type === "openkey:link-wallet:response") {
1524
+ resolve({ address: data.address, keyId: data.keyId });
1525
+ } else if (data.type === "openkey:sign-out:response") {
1526
+ resolve({ requestId: data.requestId, revoked: data.revoked });
1527
+ } else if (data.type === "openkey:externalSign:approve") {
1528
+ resolve({
1529
+ authorizationContextToken: data.authorizationContextToken,
1530
+ previewApprovalToken: data.previewApprovalToken,
1531
+ signedMessage: data.signedMessage,
1532
+ selectedActionIds: data.selectedActionIds,
1533
+ address: data.address
1534
+ });
1535
+ } else {
1536
+ resolve({
1537
+ signature: data.signature,
1538
+ address: data.address,
1539
+ signedMessage: data.signedMessage,
1540
+ selectedActionKeys: data.selectedActionKeys,
1541
+ permissions: data.permissions
1542
+ });
1543
+ }
842
1544
  } else {
843
- resolve({ signature: data.signature, address: data.address });
1545
+ reject(data.error);
844
1546
  }
845
- } else {
846
- reject(data.error);
847
- }
1547
+ });
848
1548
  }
849
1549
  };
850
1550
  window.addEventListener("message", handleMessage);
1551
+ cancel = () => {
1552
+ settle(() => {
1553
+ popup.close();
1554
+ reject({ code: "USER_CANCELLED", message: "Superseded by sign-out" });
1555
+ });
1556
+ };
1557
+ this.activeFlowCancellations.add(cancel);
1558
+ }
1559
+ cancelActiveFlows() {
1560
+ for (const cancel of [...this.activeFlowCancellations]) cancel();
851
1561
  }
852
1562
  /**
853
1563
  * Disconnect and close any open flows
854
1564
  */
855
1565
  disconnect() {
1566
+ this.cancelActiveFlows();
856
1567
  this.popup?.close();
857
1568
  this.popup = null;
858
1569
  }
@@ -924,9 +1635,14 @@ function getOpenKey(config) {
924
1635
  return defaultInstance;
925
1636
  }
926
1637
  export {
1638
+ KeyIdTypeMismatchError,
927
1639
  OpenKey,
1640
+ OpenKeyNostr,
928
1641
  OpenKeyProvider,
929
1642
  index_default as default,
930
- getOpenKey
1643
+ getOpenKey,
1644
+ resolveAuthorizeTinyCloudRouting,
1645
+ shouldRouteAuthorizeTinyCloudExternal,
1646
+ validateIframeResize
931
1647
  };
932
1648
  //# sourceMappingURL=index.mjs.map