@wordkey/sdk 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -30,12 +30,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
- WordKeyButton: () => WordKeyButton
33
+ WordKeyButton: () => WordKeyButton,
34
+ WordKeyModal: () => WordKeyModal
34
35
  });
35
36
  module.exports = __toCommonJS(src_exports);
36
37
 
37
38
  // src/WordKeyButton.tsx
38
- var import_react = require("react");
39
+ var import_react2 = require("react");
39
40
 
40
41
  // src/types.ts
41
42
  var FIELD_LABELS = {
@@ -46,6 +47,9 @@ var FIELD_LABELS = {
46
47
  };
47
48
  var DEFAULT_API_URL = "https://app.wordkey.app";
48
49
 
50
+ // src/WordKeyModal.tsx
51
+ var import_react = require("react");
52
+
49
53
  // src/fields.ts
50
54
  var STANDARD = {
51
55
  name: { key: "name", label: FIELD_LABELS.name, type: "text", isStandard: true },
@@ -110,6 +114,17 @@ var DeviceApprovalRequiredError = class extends Error {
110
114
  this.name = "DeviceApprovalRequiredError";
111
115
  }
112
116
  };
117
+ function decodeToken(token) {
118
+ try {
119
+ const part = token.split(".")[1];
120
+ if (!part) return null;
121
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
122
+ const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
123
+ return JSON.parse(json);
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
113
128
  async function verifyWord(apiUrl = DEFAULT_API_URL, publishableKey, word) {
114
129
  const wordHash = await hashWord(word);
115
130
  const res = await fetch(`${apiUrl}/api/auth/verify`, {
@@ -244,9 +259,11 @@ async function beginRedirectShare(input2) {
244
259
  const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
245
260
  const privateKeyJwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
246
261
  const pending = {
262
+ kind: "login",
247
263
  requestId: input2.requestId,
248
264
  userId: input2.userId,
249
265
  isNewUser: input2.isNewUser,
266
+ token: input2.token,
250
267
  fields: input2.fields,
251
268
  privateKeyJwk
252
269
  };
@@ -282,10 +299,12 @@ async function consumePendingRedirect() {
282
299
  }
283
300
  if (envelope.requestId !== pending.requestId) return null;
284
301
  sessionStorage.removeItem(PENDING_STORE);
285
- if (envelope.granted !== true) {
286
- return { pending, result: { granted: false, values: {} } };
302
+ if (!envelope.epk || !envelope.iv || !envelope.ct) {
303
+ if (envelope.granted !== true) {
304
+ return { pending, result: { granted: false, values: {} } };
305
+ }
306
+ return null;
287
307
  }
288
- if (!envelope.epk || !envelope.iv || !envelope.ct) return null;
289
308
  try {
290
309
  const privateKey = await crypto.subtle.importKey(
291
310
  "jwk",
@@ -316,13 +335,135 @@ async function consumePendingRedirect() {
316
335
  const parsed = JSON.parse(new TextDecoder().decode(plain));
317
336
  return {
318
337
  pending,
319
- result: { granted: true, values: parsed.values ?? {} }
338
+ result: {
339
+ granted: envelope.granted === true,
340
+ values: parsed.values ?? {}
341
+ },
342
+ signup: typeof parsed.signup?.token === "string" ? { token: parsed.signup.token } : void 0
320
343
  };
321
344
  } catch {
322
345
  return null;
323
346
  }
324
347
  }
325
348
 
349
+ // src/signup-popup.ts
350
+ var SIGNUP_TIMEOUT_MS = 20 * 60 * 1e3;
351
+ function openSignupWindow(apiUrl, publishableKey) {
352
+ const w = 440;
353
+ const h = 700;
354
+ const left = Math.max(0, (window.screenX ?? 0) + (window.outerWidth - w) / 2);
355
+ const top = Math.max(0, (window.screenY ?? 0) + (window.outerHeight - h) / 3);
356
+ try {
357
+ return window.open(
358
+ `${apiUrl}/welcome?wk_sdk=1&business=${encodeURIComponent(publishableKey)}`,
359
+ "wordkey-signup",
360
+ `popup=yes,width=${w},height=${h},left=${left},top=${top}`
361
+ );
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+ function runSignupHandshake(input2) {
367
+ const { popup, apiOrigin, requestId, signal } = input2;
368
+ return new Promise((resolve) => {
369
+ let settled = false;
370
+ const cleanup = () => {
371
+ window.removeEventListener("message", onMsg);
372
+ clearInterval(closedPoll);
373
+ clearTimeout(timer);
374
+ signal?.removeEventListener("abort", onAbort);
375
+ };
376
+ const finish = (r) => {
377
+ if (settled) return;
378
+ settled = true;
379
+ cleanup();
380
+ resolve(r);
381
+ };
382
+ const onAbort = () => {
383
+ try {
384
+ popup.close();
385
+ } catch {
386
+ }
387
+ finish({ ok: false, cancelled: true });
388
+ };
389
+ const onMsg = (e) => {
390
+ if (e.origin !== apiOrigin || e.source !== popup) return;
391
+ const data = e.data;
392
+ if (!data || typeof data !== "object") return;
393
+ if (data.type === "wordkey:signup:ready") {
394
+ popup.postMessage(
395
+ {
396
+ type: "wordkey:signup:init",
397
+ requestId,
398
+ publishableKey: input2.publishableKey,
399
+ sdkDeviceId: input2.sdkDeviceId,
400
+ fields: input2.fields
401
+ },
402
+ apiOrigin
403
+ );
404
+ }
405
+ if (data.type === "wordkey:signup:result" && data.requestId === requestId) {
406
+ finish({
407
+ ok: data.ok === true && typeof data.token === "string",
408
+ token: typeof data.token === "string" ? data.token : void 0,
409
+ profileTicket: typeof data.profileTicket === "string" ? data.profileTicket : void 0
410
+ });
411
+ }
412
+ };
413
+ window.addEventListener("message", onMsg);
414
+ const closedPoll = setInterval(() => {
415
+ if (popup.closed) finish({ ok: false, closed: true });
416
+ }, 400);
417
+ const timer = setTimeout(
418
+ () => finish({ ok: false, closed: true }),
419
+ SIGNUP_TIMEOUT_MS
420
+ );
421
+ if (signal) {
422
+ if (signal.aborted) onAbort();
423
+ else signal.addEventListener("abort", onAbort);
424
+ }
425
+ });
426
+ }
427
+ async function beginSignupRedirect(input2) {
428
+ const pair = await crypto.subtle.generateKey(
429
+ { name: "ECDH", namedCurve: "P-256" },
430
+ true,
431
+ ["deriveKey"]
432
+ );
433
+ const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
434
+ const privateKeyJwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
435
+ const pending = {
436
+ kind: "signup",
437
+ requestId: input2.requestId,
438
+ fields: input2.fields,
439
+ privateKeyJwk
440
+ };
441
+ sessionStorage.setItem(PENDING_STORE, JSON.stringify(pending));
442
+ const returnUrl = window.location.href.split("#")[0];
443
+ const request = {
444
+ v: 1,
445
+ requestId: input2.requestId,
446
+ publishableKey: input2.publishableKey,
447
+ origin: window.location.origin,
448
+ returnUrl,
449
+ sdkPub: publicJwk,
450
+ sdkDeviceId: input2.sdkDeviceId,
451
+ fields: input2.fields
452
+ };
453
+ const b64 = encodeSignupRequest(request);
454
+ window.location.assign(
455
+ `${input2.apiUrl}/welcome?wk_sdk=1&business=${encodeURIComponent(
456
+ input2.publishableKey
457
+ )}#wksignup=${b64}`
458
+ );
459
+ }
460
+ function encodeSignupRequest(value) {
461
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
462
+ let bin = "";
463
+ for (const b of bytes) bin += String.fromCharCode(b);
464
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
465
+ }
466
+
326
467
  // src/fonts.ts
327
468
  var loaded = false;
328
469
  function ensureFont() {
@@ -419,19 +560,18 @@ var ghostBtn = {
419
560
  fontFamily: FONT_BODY
420
561
  };
421
562
 
422
- // src/WordKeyButton.tsx
563
+ // src/WordKeyModal.tsx
423
564
  var import_jsx_runtime = require("react/jsx-runtime");
424
- var sizePx = { sm: 20, md: 28, lg: 38 };
425
- function WordKeyButton({
565
+ function WordKeyModal({
426
566
  publishableKey,
567
+ open,
568
+ onClose,
427
569
  onSuccess,
570
+ mode = "login",
428
571
  onError,
429
- size = "md",
430
- label = "Word?",
431
572
  requestedFields,
432
573
  apiUrl = DEFAULT_API_URL
433
574
  }) {
434
- const [open, setOpen] = (0, import_react.useState)(false);
435
575
  (0, import_react.useEffect)(() => {
436
576
  ensureFont();
437
577
  }, []);
@@ -441,55 +581,151 @@ function WordKeyButton({
441
581
  resumed.current = true;
442
582
  void consumePendingRedirect().then((r) => {
443
583
  if (!r) return;
584
+ if (r.pending.kind === "signup") {
585
+ const token = r.signup?.token;
586
+ if (!token) {
587
+ onError?.({
588
+ code: "signup_incomplete",
589
+ message: "Signup did not complete. Please try again."
590
+ });
591
+ return;
592
+ }
593
+ const claims = decodeToken(token);
594
+ onSuccess({
595
+ id: claims?.userId ?? "",
596
+ isNewUser: claims?.isNewUser ?? true,
597
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
598
+ token,
599
+ profile: r.pending.fields.length ? buildProfile(r.pending.fields, r.result.granted ? r.result.values : {}) : void 0
600
+ });
601
+ return;
602
+ }
444
603
  const profile = buildProfile(
445
604
  r.pending.fields,
446
605
  r.result.granted ? r.result.values : {}
447
606
  );
448
607
  onSuccess({
449
- id: r.pending.userId,
450
- isNewUser: r.pending.isNewUser,
608
+ id: r.pending.userId ?? "",
609
+ isNewUser: r.pending.isNewUser ?? false,
451
610
  authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
611
+ token: r.pending.token,
452
612
  profile
453
613
  });
454
614
  });
455
615
  }, []);
456
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
457
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
458
- "button",
459
- {
460
- onClick: () => setOpen(true),
461
- style: {
462
- background: "transparent",
463
- border: "none",
464
- padding: 0,
465
- cursor: "pointer",
466
- fontFamily: FONT_DISPLAY,
467
- fontWeight: 600,
468
- fontSize: sizePx[size],
469
- lineHeight: 1
470
- },
471
- "aria-label": label,
472
- children: [
473
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purple }, children: "Word" }),
474
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purpleL }, children: "?" })
475
- ]
476
- }
477
- ),
478
- open && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
479
- AuthModal,
480
- {
481
- publishableKey,
616
+ if (!open) return null;
617
+ const common = {
618
+ publishableKey,
619
+ apiUrl,
620
+ requestedFields,
621
+ onClose,
622
+ onSuccess: (u) => {
623
+ onClose();
624
+ onSuccess(u);
625
+ },
626
+ onError: (e) => onError?.(e)
627
+ };
628
+ return mode === "signup" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SignupModal, { ...common }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AuthModal, { ...common });
629
+ }
630
+ function SignupModal({
631
+ publishableKey,
632
+ apiUrl,
633
+ requestedFields,
634
+ onClose,
635
+ onSuccess,
636
+ onError
637
+ }) {
638
+ const fields = (0, import_react.useMemo)(
639
+ () => requestedFields ? resolveFields(requestedFields) : [],
640
+ [requestedFields]
641
+ );
642
+ const apiOrigin = (0, import_react.useMemo)(() => {
643
+ try {
644
+ return new URL(apiUrl).origin;
645
+ } catch {
646
+ return apiUrl;
647
+ }
648
+ }, [apiUrl]);
649
+ const [failed, setFailed] = (0, import_react.useState)(null);
650
+ const abortRef = (0, import_react.useRef)(null);
651
+ if (!abortRef.current) abortRef.current = new AbortController();
652
+ (0, import_react.useEffect)(() => () => abortRef.current?.abort(), []);
653
+ const started = (0, import_react.useRef)(false);
654
+ (0, import_react.useEffect)(() => {
655
+ if (started.current) return;
656
+ started.current = true;
657
+ const popup = openSignupWindow(apiUrl, publishableKey);
658
+ if (!popup) {
659
+ void beginSignupRedirect({
482
660
  apiUrl,
483
- requestedFields,
484
- onClose: () => setOpen(false),
485
- onSuccess: (u) => {
486
- setOpen(false);
487
- onSuccess(u);
488
- },
489
- onError: (e) => onError?.(e)
661
+ requestId: newRequestId(),
662
+ publishableKey,
663
+ sdkDeviceId: getDeviceId(),
664
+ fields
665
+ });
666
+ return;
667
+ }
668
+ const requestId = newRequestId();
669
+ void (async () => {
670
+ const hs = await runSignupHandshake({
671
+ popup,
672
+ apiOrigin,
673
+ requestId,
674
+ publishableKey,
675
+ sdkDeviceId: getDeviceId(),
676
+ fields,
677
+ signal: abortRef.current?.signal
678
+ });
679
+ if (hs.cancelled) return;
680
+ if (hs.closed) {
681
+ onClose();
682
+ return;
490
683
  }
491
- )
492
- ] });
684
+ if (!hs.ok || !hs.token) {
685
+ setFailed("Signup could not be completed. Please try again.");
686
+ onError({
687
+ code: "signup_failed",
688
+ message: "Signup could not be completed"
689
+ });
690
+ return;
691
+ }
692
+ const claims = decodeToken(hs.token);
693
+ const finish = (profile) => onSuccess({
694
+ id: claims?.userId ?? "",
695
+ isNewUser: claims?.isNewUser ?? true,
696
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
697
+ token: hs.token,
698
+ profile
699
+ });
700
+ if (fields.length === 0) {
701
+ finish();
702
+ return;
703
+ }
704
+ const share = await runPopupFlow({
705
+ popup,
706
+ apiOrigin,
707
+ requestId,
708
+ publishableKey,
709
+ ticket: hs.profileTicket,
710
+ fields,
711
+ signal: abortRef.current?.signal
712
+ });
713
+ if (share.cancelled) return;
714
+ finish(buildProfile(fields, share.granted ? share.values : {}));
715
+ })();
716
+ }, []);
717
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: overlay, role: "dialog", "aria-modal": "true", onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: panel, onClick: (e) => e.stopPropagation(), children: [
718
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Header, {}),
719
+ failed ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
720
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorText, { children: failed }),
721
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Close" }) })
722
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center", padding: "12px 0" }, children: [
723
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, {}),
724
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { style: { fontFamily: FONT_DISPLAY, fontSize: 22, margin: "4px 0" }, children: "Get your word in the WordKey window" }),
725
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted, fontSize: 13 }, children: "Finish setting up in the window that just opened. Closing it cancels \u2014 nothing is created without you." }),
726
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Cancel" }) })
727
+ ] })
728
+ ] }) });
493
729
  }
494
730
  function AuthModal({
495
731
  publishableKey,
@@ -524,7 +760,12 @@ function AuthModal({
524
760
  setStep("authenticating");
525
761
  try {
526
762
  const result = await verifyWord(apiUrl, publishableKey, word);
527
- await completeAuth(result.userId, result.isNewUser, result.profileTicket);
763
+ await completeAuth(
764
+ result.userId,
765
+ result.isNewUser,
766
+ result.token,
767
+ result.profileTicket
768
+ );
528
769
  } catch (e) {
529
770
  if (e instanceof DeviceApprovalRequiredError) {
530
771
  await startDeviceApproval();
@@ -536,11 +777,12 @@ function AuthModal({
536
777
  onError({ code: "auth_failed", message });
537
778
  }
538
779
  }
539
- async function completeAuth(userId, isNewUser, profileTicket) {
780
+ async function completeAuth(userId, isNewUser, token, profileTicket) {
540
781
  const finish = (profile) => onSuccess({
541
782
  id: userId,
542
783
  isNewUser,
543
784
  authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
785
+ token,
544
786
  profile
545
787
  });
546
788
  if (fields.length === 0) {
@@ -557,7 +799,8 @@ function AuthModal({
557
799
  ticket: profileTicket,
558
800
  fields,
559
801
  userId,
560
- isNewUser
802
+ isNewUser,
803
+ token
561
804
  });
562
805
  return;
563
806
  }
@@ -629,11 +872,12 @@ function AuthModal({
629
872
  const userId = claims?.userId ?? "";
630
873
  const isNewUser = claims?.isNewUser ?? false;
631
874
  if (fields.length === 0) {
632
- await completeAuth(userId, isNewUser, data.profileTicket);
875
+ await completeAuth(userId, isNewUser, data.token, data.profileTicket);
633
876
  } else {
634
877
  pendingApproval.current = {
635
878
  userId,
636
879
  isNewUser,
880
+ token: data.token,
637
881
  profileTicket: data.profileTicket
638
882
  };
639
883
  setStep("approved");
@@ -738,7 +982,8 @@ function AuthModal({
738
982
  style: primaryBtn(false),
739
983
  onClick: () => {
740
984
  const p = pendingApproval.current;
741
- if (p) void completeAuth(p.userId, p.isNewUser, p.profileTicket);
985
+ if (p)
986
+ void completeAuth(p.userId, p.isNewUser, p.token, p.profileTicket);
742
987
  },
743
988
  children: "Continue"
744
989
  }
@@ -797,17 +1042,6 @@ function valuePresent(v) {
797
1042
  if (typeof v === "string") return v.trim().length > 0;
798
1043
  return Object.values(v).some((x) => x && String(x).trim().length > 0);
799
1044
  }
800
- function decodeToken(token) {
801
- try {
802
- const part = token.split(".")[1];
803
- if (!part) return null;
804
- const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
805
- const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
806
- return JSON.parse(json);
807
- } catch {
808
- return null;
809
- }
810
- }
811
1045
  function buildProfile(fields, granted) {
812
1046
  const profile = {};
813
1047
  const custom = {};
@@ -856,7 +1090,66 @@ function Spinner() {
856
1090
  }
857
1091
  );
858
1092
  }
1093
+
1094
+ // src/WordKeyButton.tsx
1095
+ var import_jsx_runtime2 = require("react/jsx-runtime");
1096
+ var sizePx = { sm: 20, md: 28, lg: 38 };
1097
+ function WordKeyButton({
1098
+ publishableKey,
1099
+ onSuccess,
1100
+ onError,
1101
+ size = "md",
1102
+ label,
1103
+ mode = "login",
1104
+ requestedFields,
1105
+ apiUrl = DEFAULT_API_URL
1106
+ }) {
1107
+ const [open, setOpen] = (0, import_react2.useState)(false);
1108
+ (0, import_react2.useEffect)(() => {
1109
+ ensureFont();
1110
+ }, []);
1111
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1112
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1113
+ "button",
1114
+ {
1115
+ onClick: () => setOpen(true),
1116
+ style: {
1117
+ background: "transparent",
1118
+ border: "none",
1119
+ padding: 0,
1120
+ cursor: "pointer",
1121
+ fontFamily: FONT_DISPLAY,
1122
+ fontWeight: 600,
1123
+ fontSize: sizePx[size],
1124
+ lineHeight: 1
1125
+ },
1126
+ "aria-label": label ?? (mode === "signup" ? "Get your word" : "Word?"),
1127
+ children: mode === "signup" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1128
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purple }, children: "Get your " }),
1129
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purpleL }, children: "word" })
1130
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1131
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purple }, children: "Word" }),
1132
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purpleL }, children: "?" })
1133
+ ] })
1134
+ }
1135
+ ),
1136
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1137
+ WordKeyModal,
1138
+ {
1139
+ publishableKey,
1140
+ open,
1141
+ mode,
1142
+ onClose: () => setOpen(false),
1143
+ onSuccess,
1144
+ onError,
1145
+ requestedFields,
1146
+ apiUrl
1147
+ }
1148
+ )
1149
+ ] });
1150
+ }
859
1151
  // Annotate the CommonJS export names for ESM import in node:
860
1152
  0 && (module.exports = {
861
- WordKeyButton
1153
+ WordKeyButton,
1154
+ WordKeyModal
862
1155
  });