@synqauth/js 0.2.0 → 0.4.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
@@ -1,4 +1,4 @@
1
- import { a as readSavedSignIn, c as problemError, i as newSignIn, l as toSynqError, n as secureUrl, o as SynqError, r as SignInFlow, s as isSynqError, t as createApiClient } from "./api-CeYAjkvX.js";
1
+ import { a as readSavedSignIn, c as SynqError, d as toSynqError, i as newSignIn, l as isSynqError, n as secureUrl, r as SignInFlow, s as solveEmailWork, t as createApiClient, u as problemError } from "./api-D2jDzTRO.js";
2
2
  import * as oauth from "oauth4webapi";
3
3
  //#region src/storage.ts
4
4
  /**
@@ -317,6 +317,7 @@ var Client = class {
317
317
  }
318
318
  async signOut(options = {}) {
319
319
  const stored = await this.#load();
320
+ if (options.stay) await this.#endBrandSession(stored);
320
321
  const refreshToken = await this.#readRefreshToken();
321
322
  this.#generation += 1;
322
323
  await this.#clear();
@@ -335,7 +336,7 @@ var Client = class {
335
336
  } catch (error) {
336
337
  if (attempt === 2) console.warn("@synqauth/js: revoking the refresh token at sign-out failed; it stays usable until it expires", toSynqError(error).code);
337
338
  }
338
- if (!server.end_session_endpoint || stored?.via === "app") return null;
339
+ if (options.stay || !server.end_session_endpoint || stored?.via === "app") return null;
339
340
  const url = new URL(server.end_session_endpoint);
340
341
  url.searchParams.set("client_id", this.#options.clientId);
341
342
  if (stored?.idToken) url.searchParams.set("id_token_hint", stored.idToken);
@@ -422,6 +423,48 @@ var Client = class {
422
423
  });
423
424
  return session;
424
425
  }
426
+ /**
427
+ * Ends the browser session at the brand that `stored`'s ID token names,
428
+ * as the app, at the issuer's session revocation endpoint (AERO-2684).
429
+ * Nothing to end for a session signed in on the app's own screens (its
430
+ * refresh token's revocation ends it) or without an ID token.
431
+ */
432
+ async #endBrandSession(stored) {
433
+ if (!this.#options.clientSecret) throw new SynqError("invalid_options", "Ending the brand's session from here takes a confidential app's clientSecret: sign out with the redirect instead");
434
+ if (!stored?.idToken || stored.via === "app") return;
435
+ const server = await this.#discover();
436
+ const endpoint = server.synq_session_revocation_endpoint;
437
+ if (typeof endpoint !== "string") throw new SynqError("discovery_failed", "The issuer can't end a session from here (no synq_session_revocation_endpoint)");
438
+ const url = secureUrl(endpoint, "The session revocation endpoint");
439
+ const body = new URLSearchParams({ id_token_hint: stored.idToken });
440
+ const headers = new Headers({
441
+ accept: "application/json",
442
+ "content-type": "application/x-www-form-urlencoded"
443
+ });
444
+ await this.#auth(server, this.#client, body, headers);
445
+ let response;
446
+ try {
447
+ response = await (this.#options.fetch ?? fetch)(url, {
448
+ method: "POST",
449
+ headers,
450
+ body,
451
+ redirect: "manual"
452
+ });
453
+ } catch (error) {
454
+ throw toSynqError(error);
455
+ }
456
+ if (response.ok) return;
457
+ let answer = {};
458
+ try {
459
+ answer = await response.json();
460
+ } catch {}
461
+ const code = typeof answer.error === "string" ? answer.error : "invalid_response";
462
+ const description = typeof answer.error_description === "string" ? answer.error_description : void 0;
463
+ throw new SynqError(code, description ?? `Synq answered ${response.status}`, {
464
+ status: response.status,
465
+ description
466
+ });
467
+ }
425
468
  #discover() {
426
469
  this.#server ??= (async () => {
427
470
  try {
@@ -517,11 +560,12 @@ function accountApiUrl(issuer) {
517
560
  /** A client for the brand's account API, for an app that holds its own session: everything the signed-in person can do to their own account. */
518
561
  function createAccountClient(options) {
519
562
  if (typeof options.accessToken !== "function") throw new SynqError("invalid_options", "Give the account client an accessToken function");
520
- const raw = createApiClient({
563
+ const api = createApiClient({
521
564
  baseUrl: accountApiUrl(options.issuer),
522
565
  credential: options.accessToken,
523
566
  fetch: options.fetch
524
567
  });
568
+ const raw = api;
525
569
  async function run(send, pick) {
526
570
  try {
527
571
  const json = await send();
@@ -540,18 +584,89 @@ function createAccountClient(options) {
540
584
  ...request,
541
585
  signal: opts.signal
542
586
  }));
543
- const list = (path, opts = {}) => run(() => raw.get(path, { signal: opts.signal }), (json) => json.data);
587
+ const pages = api.paginate;
588
+ const list = (path, opts = {}) => run(async () => {
589
+ const all = [];
590
+ for await (const item of pages(path, {
591
+ query: { limit: 200 },
592
+ signal: opts.signal
593
+ })) all.push(item);
594
+ return all;
595
+ });
544
596
  const change = (method, path, opts = {}, request = {}) => run(() => raw[method](path, {
545
597
  ...request,
546
598
  idempotencyKey: opts.idempotencyKey ?? crypto.randomUUID(),
547
599
  signal: opts.signal
548
600
  }), (json) => json === void 0 ? null : json);
601
+ async function uploadPicture(picture, opts = {}) {
602
+ const bytes = new Uint8Array(picture instanceof Uint8Array ? picture : picture instanceof ArrayBuffer ? picture : await picture.arrayBuffer());
603
+ const contentType = opts.contentType ?? (typeof Blob !== "undefined" && picture instanceof Blob ? picture.type : "");
604
+ if (!contentType) return {
605
+ data: null,
606
+ error: new SynqError("invalid_options", "Say what the picture is: its contentType")
607
+ };
608
+ const key = opts.idempotencyKey;
609
+ const started = await change("post", "/v1/profile/picture/uploads", {
610
+ ...opts,
611
+ idempotencyKey: key && `${key}:upload`
612
+ }, { body: {
613
+ content_type: contentType,
614
+ content_length: bytes.byteLength
615
+ } });
616
+ if (started.error) return started;
617
+ try {
618
+ const put = await (options.fetch ?? fetch)(started.data.url, {
619
+ method: "PUT",
620
+ headers: started.data.headers,
621
+ body: bytes,
622
+ signal: opts.signal
623
+ });
624
+ await put.body?.cancel();
625
+ if (!put.ok) return {
626
+ data: null,
627
+ error: new SynqError("network_error", `Synq's storage refused the picture (${put.status})`, { status: put.status })
628
+ };
629
+ } catch (error) {
630
+ return {
631
+ data: null,
632
+ error: new SynqError("network_error", String(error), { cause: error })
633
+ };
634
+ }
635
+ return change("put", "/v1/profile/picture", {
636
+ ...opts,
637
+ idempotencyKey: key && `${key}:choose`
638
+ }, { body: { file_id: started.data.file_id } });
639
+ }
640
+ async function linkWay(method, opts) {
641
+ if (method !== "passkey") return change("post", "/v1/link-requests", opts, { body: { method } });
642
+ const offered = await list("/v1/link-methods", { signal: opts?.signal });
643
+ if (offered.error) return offered;
644
+ if (!offered.data.some((one) => one.method === "passkey")) return {
645
+ data: null,
646
+ error: new SynqError("sign_in_method_unavailable", "This brand doesn’t offer passkeys", { status: 409 })
647
+ };
648
+ return {
649
+ data: {
650
+ method: "passkey",
651
+ authorization_params: {
652
+ prompt: "login",
653
+ synq_intent: "add-passkey"
654
+ }
655
+ },
656
+ error: null
657
+ };
658
+ }
549
659
  return {
550
660
  me: (opts) => read("/v1/profile", opts),
551
661
  user: {
552
662
  update: (changes, opts) => change("patch", "/v1/profile", opts, { body: changes }),
553
663
  metadata: (opts) => read("/v1/metadata", opts),
554
- usernameAvailable: (username, opts) => read("/v1/usernames/{username}", opts, { path: { username } })
664
+ usernameAvailable: (username, opts) => read("/v1/usernames/{username}", opts, { path: { username } }),
665
+ picture: {
666
+ upload: uploadPicture,
667
+ fromAccount: (identity, opts) => change("put", "/v1/profile/picture", opts, { body: { identity } }),
668
+ remove: (opts) => change("delete", "/v1/profile/picture", opts)
669
+ }
555
670
  },
556
671
  wallets: {
557
672
  list: (opts) => list("/v1/wallets", opts),
@@ -559,11 +674,14 @@ function createAccountClient(options) {
559
674
  path: { wallet },
560
675
  body: changes
561
676
  }),
562
- remove: (wallet, opts) => change("delete", "/v1/identities/{identity}", opts, { path: { identity: wallet } })
677
+ remove: (wallet, opts) => change("delete", "/v1/identities/{identity}", opts, { path: { identity: wallet } }),
678
+ add: (siws, opts) => change("post", "/v1/wallets", opts, { body: { siws } }),
679
+ move: (ticket, opts) => change("post", "/v1/wallet-moves", opts, { body: { ticket } })
563
680
  },
564
681
  accounts: {
565
682
  list: (opts) => list("/v1/identities", opts),
566
- link: (method, opts) => change("post", "/v1/link-requests", opts, { body: { method } }),
683
+ linkable: (opts) => list("/v1/link-methods", opts),
684
+ link: linkWay,
567
685
  unlink: (identity, opts) => change("delete", "/v1/identities/{identity}", opts, { path: { identity } })
568
686
  },
569
687
  sessions: {
@@ -593,13 +711,30 @@ function createAccountClient(options) {
593
711
  }
594
712
  };
595
713
  }
714
+ /**
715
+ * The conflict a `wallet_in_use` error carries (AERO-2683): whether the
716
+ * wallet is the other account's only way in, and the `move_ticket` that
717
+ * moves it here, or null. Undefined for any other error.
718
+ */
719
+ function walletConflict(error) {
720
+ if (!(error instanceof SynqError) || error.code !== "wallet_in_use") return void 0;
721
+ const conflict = error.cause?.conflict;
722
+ if (!conflict || typeof conflict.only_way_in !== "boolean") return void 0;
723
+ return {
724
+ only_way_in: conflict.only_way_in,
725
+ move_ticket: typeof conflict.move_ticket === "string" ? conflict.move_ticket : null,
726
+ expires_at: typeof conflict.expires_at === "string" ? conflict.expires_at : null
727
+ };
728
+ }
596
729
  //#endregion
597
730
  //#region src/generated/challenge.ts
598
731
  /** The ways in an app's own screens sign people in with (`synq_method`). */
599
732
  const CHALLENGE_METHODS = [
600
733
  "password",
601
734
  "email",
602
- "passkey"
735
+ "passkey",
736
+ "wallet",
737
+ "test"
603
738
  ];
604
739
  /** What went wrong with a step (`synq_problem`): branch on it, never on the English. */
605
740
  const CHALLENGE_PROBLEMS = [
@@ -617,13 +752,126 @@ const CHALLENGE_PROBLEMS = [
617
752
  "address_suppressed",
618
753
  "team_only",
619
754
  "send_failed",
755
+ "work_required",
620
756
  "passkey_failed",
621
757
  "passkey_unknown",
622
758
  "passkey_expired",
759
+ "wallet_failed",
760
+ "wallet_expired",
623
761
  "slow_down",
624
762
  "locked",
625
763
  "limited"
626
764
  ];
765
+ /**
766
+ * Why a hosted sign-in went back to the app with `access_denied` or
767
+ * `unmet_authentication_requirements`, each with the exact
768
+ * `error_description` the issuer sends for it: read the reason from it.
769
+ */
770
+ const SIGN_IN_ENDINGS = {
771
+ cancelled: "the user cancelled the sign-in",
772
+ consent_denied: "the user denied the request",
773
+ token_gate_refused: "the user does not hold the tokens this app requires",
774
+ token_gate_unavailable: "the user's tokens could not be checked",
775
+ method_not_offered: "wallets can't sign in here",
776
+ passkey_required: "the user did not sign in with a passkey, which the app requires",
777
+ level_not_met: "the sign-in did not reach the level the app requires"
778
+ };
779
+ //#endregion
780
+ //#region src/sign-in-endings.ts
781
+ const BY_DESCRIPTION = new Map(Object.entries(SIGN_IN_ENDINGS).map(([ending, words]) => [words, ending]));
782
+ /** Which ending a callback's `error_description` names, or undefined for one Synq didn't write. */
783
+ function signInEndingOf(description) {
784
+ return description ? BY_DESCRIPTION.get(description) : void 0;
785
+ }
786
+ //#endregion
787
+ //#region src/sign-in-methods.ts
788
+ /** Where the issuer lists an app's sign-in methods. */
789
+ function signInMethodsUrl(issuer, clientId) {
790
+ const base = secureUrl(issuer, "The issuer");
791
+ const url = new URL(`${base.href.replace(/\/+$/, "")}/sign-in/methods`);
792
+ url.searchParams.set("client_id", clientId);
793
+ return url;
794
+ }
795
+ /**
796
+ * The ways `clientId`'s sign-in offers at `issuer`, in the brand's order:
797
+ * public, so it works for someone signed out, from a server or the app's own
798
+ * pages (the issuer lets the origins of the app's redirect URIs read it).
799
+ * The issuer lets it be kept a minute. Throws a `SynqError`: the issuer's
800
+ * `invalid_client` for an app it doesn't know, `network_error`, or
801
+ * `invalid_response`.
802
+ */
803
+ async function signInMethods(options) {
804
+ if (!options.clientId) throw new SynqError("invalid_options", "The clientId is missing");
805
+ const url = signInMethodsUrl(options.issuer, options.clientId);
806
+ let response;
807
+ try {
808
+ response = await (options.fetch ?? fetch)(url, {
809
+ headers: { accept: "application/json" },
810
+ ...options.signal ? { signal: options.signal } : {}
811
+ });
812
+ } catch (error) {
813
+ throw toSynqError(error);
814
+ }
815
+ let body;
816
+ try {
817
+ body = await response.json();
818
+ } catch (error) {
819
+ throw new SynqError("invalid_response", "Synq answered the sign-in methods with no JSON", {
820
+ status: response.status,
821
+ cause: error
822
+ });
823
+ }
824
+ if (!response.ok) {
825
+ const { error, error_description: description } = body ?? {};
826
+ const code = typeof error === "string" ? error : "invalid_response";
827
+ const detail = typeof description === "string" ? description : void 0;
828
+ throw new SynqError(code, detail ?? code, {
829
+ status: response.status,
830
+ description: detail
831
+ });
832
+ }
833
+ return readMethodList(body);
834
+ }
835
+ /** `body` as a method list, checked: what the issuer and any route mount answer. */
836
+ function readMethodList(body) {
837
+ const list = body;
838
+ if (!list || typeof list !== "object" || !Array.isArray(list.methods) || typeof list.show_last_used !== "boolean" || !list.methods.every((method) => method !== null && typeof method === "object" && typeof method.id === "string" && typeof method.kind === "string" && typeof method.name === "string" && (method.icon === null || typeof method.icon === "string") && typeof method.in_place === "boolean" && typeof method.popup === "boolean" && Array.isArray(method.scopes))) throw new SynqError("invalid_response", "Synq's sign-in methods weren't a list of methods");
839
+ return {
840
+ methods: list.methods,
841
+ show_last_used: list.show_last_used
842
+ };
843
+ }
844
+ /** Marks the apps people open links from add to their built-in browser's user agent. */
845
+ const IN_APP_MARKS = [
846
+ /\bTwitter(?:Android)?\b/i,
847
+ /\bLinkedInApp\b/i,
848
+ /\bSlack(?:_SSB)?\//i,
849
+ /\bInstagram\b/i,
850
+ /\bFB(?:AN|AV|_IAB)\b/,
851
+ /\bmusical_ly|\bTikTok\b|\bBytedanceWebview\b/i,
852
+ /\bSnapchat\b/i,
853
+ /\bLine\//,
854
+ /\bMicroMessenger\b/i
855
+ ];
856
+ /**
857
+ * Whether the page is in another app's built-in browser, where some ways in
858
+ * fail: Google refuses to sign anyone in there (`disallowed_useragent`),
859
+ * and passkeys usually can't be reached. `android-webview` for an Android
860
+ * app's web view, `ios-in-app` for an iPhone or iPad app's, null for a real
861
+ * browser (or when unsure). Hide those methods there, or offer to open the
862
+ * page in the browser. Reads `userAgent`, the browser's own by default; a
863
+ * server passes the request's.
864
+ */
865
+ function embeddedBrowser(userAgent) {
866
+ const agent = userAgent ?? (typeof navigator === "undefined" ? void 0 : navigator.userAgent) ?? "";
867
+ if (!agent) return null;
868
+ const ios = /\b(iPhone|iPad|iPod)\b/.test(agent);
869
+ const android = /\bAndroid\b/.test(agent);
870
+ if (android && /;\s*wv\)/.test(agent)) return "android-webview";
871
+ if (IN_APP_MARKS.some((mark) => mark.test(agent))) return ios ? "ios-in-app" : android ? "android-webview" : null;
872
+ if (ios && /AppleWebKit\//.test(agent) && !/\bSafari\//.test(agent)) return "ios-in-app";
873
+ return null;
874
+ }
627
875
  //#endregion
628
876
  //#region src/generated/errors.ts
629
877
  /** Each `code` a Synq API's problem documents carry, with its HTTP status. */
@@ -639,6 +887,7 @@ const ERROR_CODES = {
639
887
  idempotency_key_conflict: 422,
640
888
  idempotency_key_in_flight: 409,
641
889
  conflict: 409,
890
+ ambiguous_name: 409,
642
891
  org_slug_taken: 409,
643
892
  issuer_slug_taken: 409,
644
893
  issuer_slug_immutable: 422,
@@ -654,8 +903,16 @@ const ERROR_CODES = {
654
903
  passkey_exists: 409,
655
904
  sign_in_method_unavailable: 409,
656
905
  identity_limit_reached: 409,
906
+ wallet_in_use: 409,
907
+ wallet_signature_rejected: 400,
657
908
  connection_rejected: 422,
909
+ own_app_required: 422,
910
+ scope_not_allowed: 422,
911
+ scope_unavailable: 422,
912
+ scope_needs_own_app: 422,
658
913
  token_gates_unavailable: 409,
914
+ discord_unavailable: 409,
915
+ telegram_unavailable: 409,
659
916
  passkey_rejected: 400,
660
917
  email_undeliverable: 422,
661
918
  email_domain_unavailable: 409,
@@ -667,6 +924,7 @@ const ERROR_CODES = {
667
924
  no_billing_account: 409,
668
925
  no_subscription: 409,
669
926
  payment_failed: 402,
927
+ trial_unavailable: 409,
670
928
  custom_domain_exists: 409,
671
929
  hostname_taken: 409,
672
930
  invalid_hostname: 422,
@@ -680,11 +938,224 @@ const ERROR_CODES = {
680
938
  upload_expired: 410,
681
939
  file_rejected: 422,
682
940
  upload_limit_reached: 409,
941
+ picture_unavailable: 409,
683
942
  api_keys_not_allowed: 403,
684
943
  api_key_limit_reached: 409,
685
944
  rate_limited: 429,
945
+ method_not_allowed: 405,
686
946
  internal_error: 500,
687
- service_unavailable: 503
947
+ service_unavailable: 503,
948
+ storage_unavailable: 503
688
949
  };
689
950
  //#endregion
690
- export { CHALLENGE_METHODS, CHALLENGE_PROBLEMS, ERROR_CODES, SignInFlow, SynqError, accountApiUrl, browserLocalStorage, browserSessionStorage, createAccountClient, createSynqClient, isSynqError, linkSignIn, memoryStorage, problemError };
951
+ //#region src/avatar.ts
952
+ /** The four 32-bit words of cyrb128 over `seed`. */
953
+ function cyrb128(seed) {
954
+ let h1 = 1779033703;
955
+ let h2 = 3144134277;
956
+ let h3 = 1013904242;
957
+ let h4 = 2773480762;
958
+ for (let i = 0; i < seed.length; i++) {
959
+ const k = seed.charCodeAt(i);
960
+ h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
961
+ h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
962
+ h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
963
+ h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
964
+ }
965
+ h1 = Math.imul(h3 ^ h1 >>> 18, 597399067);
966
+ h2 = Math.imul(h4 ^ h2 >>> 22, 2869860233);
967
+ h3 = Math.imul(h1 ^ h3 >>> 17, 951274213);
968
+ h4 = Math.imul(h2 ^ h4 >>> 19, 2716044179);
969
+ h1 ^= h2 ^ h3 ^ h4;
970
+ h2 ^= h1;
971
+ h3 ^= h1;
972
+ h4 ^= h1;
973
+ return [
974
+ h1 >>> 0,
975
+ h2 >>> 0,
976
+ h3 >>> 0,
977
+ h4 >>> 0
978
+ ];
979
+ }
980
+ /**
981
+ * Which of a set of default pictures is the person's: of `set`, the one
982
+ * whose id (`idOf`, the item itself for strings: a file id, a name, a URL
983
+ * that never changes) scores highest with their seed (rendezvous hashing).
984
+ * The same seed always picks the same one, and the set can grow and shrink
985
+ * around it: adding one to a set of n moves only about 1 in n + 1 people,
986
+ * to the new one, and removing one moves only the people who had it.
987
+ * Undefined for an empty set.
988
+ */
989
+ function defaultPick(seed, set, idOf = String) {
990
+ let best;
991
+ let high = -1;
992
+ let low = -1;
993
+ for (const item of set) {
994
+ const [w0, w1] = cyrb128(`${seed}:${idOf(item)}`);
995
+ if (w0 > high || w0 === high && w1 > low) {
996
+ best = item;
997
+ high = w0;
998
+ low = w1;
999
+ }
1000
+ }
1001
+ return best;
1002
+ }
1003
+ /** sRGB, 0 to 1 per channel, of OKLCH `l c h` (h in degrees), unclipped. */
1004
+ function oklchToLinear(l, c, h) {
1005
+ const rad = h * Math.PI / 180;
1006
+ const a = c * Math.cos(rad);
1007
+ const b = c * Math.sin(rad);
1008
+ const l_ = (l + .3963377774 * a + .2158037573 * b) ** 3;
1009
+ const m_ = (l - .1055613458 * a - .0638541728 * b) ** 3;
1010
+ const s_ = (l - .0894841775 * a - 1.291485548 * b) ** 3;
1011
+ return [
1012
+ 4.0767416621 * l_ - 3.3077115913 * m_ + .2309699292 * s_,
1013
+ -1.2684380046 * l_ + 2.6097574011 * m_ - .3413193965 * s_,
1014
+ -.0041960863 * l_ - .7034186147 * m_ + 1.707614701 * s_
1015
+ ];
1016
+ }
1017
+ const inGamut = (rgb) => rgb.every((v) => v >= -1e-6 && v <= 1 + 1e-6);
1018
+ const encode = (v) => {
1019
+ const x = Math.min(1, Math.max(0, v));
1020
+ const e = x <= .0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - .055;
1021
+ return Math.round(e * 255);
1022
+ };
1023
+ const decode = (byte) => {
1024
+ const x = byte / 255;
1025
+ return x <= .04045 ? x / 12.92 : ((x + .055) / 1.055) ** 2.4;
1026
+ };
1027
+ /** OKLCH as an sRGB byte triple, its chroma cut back until it fits sRGB. */
1028
+ function oklchBytes(l, c, h) {
1029
+ let rgb = oklchToLinear(l, c, h);
1030
+ if (!inGamut(rgb)) {
1031
+ let low = 0;
1032
+ let high = c;
1033
+ for (let i = 0; i < 24; i++) {
1034
+ const mid = (low + high) / 2;
1035
+ if (inGamut(oklchToLinear(l, mid, h))) low = mid;
1036
+ else high = mid;
1037
+ }
1038
+ rgb = oklchToLinear(l, low, h);
1039
+ }
1040
+ return [
1041
+ encode(rgb[0]),
1042
+ encode(rgb[1]),
1043
+ encode(rgb[2])
1044
+ ];
1045
+ }
1046
+ const hex = (bytes) => `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
1047
+ /** WCAG's contrast of white on `bytes`. */
1048
+ function whiteContrast(bytes) {
1049
+ const [r, g, b] = bytes.map(decode);
1050
+ return 1.05 / (.2126 * r + .7152 * g + .0722 * b + .05);
1051
+ }
1052
+ /** OKLCH as hex, darkened in steps of 0.01 until white on it is at least 4.5:1 when `forWhite`. */
1053
+ function color(l, c, h, forWhite) {
1054
+ let lightness = l;
1055
+ let bytes = oklchBytes(lightness, c, h);
1056
+ while (forWhite && whiteContrast(bytes) < 4.5 && lightness > .2) {
1057
+ lightness = Math.round((lightness - .01) * 100) / 100;
1058
+ bytes = oklchBytes(lightness, c, h);
1059
+ }
1060
+ return hex(bytes);
1061
+ }
1062
+ /**
1063
+ * How much a hue is in the band where a dark color turns olive or brown
1064
+ * (yellows, 55° to 125° in OKLCH): 1 at its middle, 0 outside it.
1065
+ */
1066
+ function muddiness(hue) {
1067
+ const off = Math.abs((hue - 90 + 540) % 360 - 180);
1068
+ return off >= 35 ? 0 : Math.cos(off / 35 * (Math.PI / 2)) ** 2;
1069
+ }
1070
+ /**
1071
+ * A monogram's hue: the circle without that band, spread evenly over the
1072
+ * rest, since white text needs a darkness that band can't have and stay
1073
+ * clean.
1074
+ */
1075
+ function monogramHue(hue) {
1076
+ const spread = hue * 290 / 360;
1077
+ return Math.round(spread < 55 ? spread : spread + 70) % 360;
1078
+ }
1079
+ /**
1080
+ * What the avatar for `seed` is drawn from: its two colors, angle,
1081
+ * highlight and, for a monogram, its initials. `avatarSvg` draws it; draw
1082
+ * it yourself from these to match it in another renderer.
1083
+ */
1084
+ function avatarParts(seed, options = {}) {
1085
+ const [w0, w1, w2, w3] = cyrb128(seed);
1086
+ const h1 = w0 % 360;
1087
+ const h2 = (h1 + 40 + w1 % 80) % 360;
1088
+ const text = options.style === "monogram" ? cleanInitials(options.initials) : void 0;
1089
+ const monogram = text !== void 0;
1090
+ const lift = Math.round(muddiness(h2) * 12) / 100;
1091
+ const m1 = monogramHue(h1);
1092
+ const step = 25 + w1 % 45;
1093
+ const near = (m1 + step) % 360;
1094
+ const m2 = near >= 55 && near < 125 ? (m1 - step + 360) % 360 : near;
1095
+ return {
1096
+ from: monogram ? color(.55, .12, m1, true) : color(.82, .1, h1, false),
1097
+ to: monogram ? color(.45, .12, m2, true) : color(.62 + lift, .14, h2, false),
1098
+ angle: w2 % 360,
1099
+ highlight: {
1100
+ x: 16 + w3 % 32,
1101
+ y: 16 + (w3 >>> 5) % 32
1102
+ },
1103
+ ...monogram ? { text } : {}
1104
+ };
1105
+ }
1106
+ /** At most two letters, uppercase, or undefined for none. */
1107
+ function cleanInitials(initials) {
1108
+ const letters = [...(initials ?? "").replace(/[\s\p{C}]/gu, "")].slice(0, 2).join("");
1109
+ return letters ? letters.toUpperCase() : void 0;
1110
+ }
1111
+ /** Up to two initials of a name, a username or an email address's local part; never of a wallet address. */
1112
+ function initialsOf(person) {
1113
+ const candidates = [
1114
+ person.name,
1115
+ person.username,
1116
+ person.email?.split("@")[0]
1117
+ ];
1118
+ for (const candidate of candidates) {
1119
+ const text = candidate?.trim();
1120
+ if (!text || WALLET.test(text)) continue;
1121
+ const letters = text.split(/[\s._-]+/u).filter((word) => /\p{L}|\p{N}/u.test(word)).slice(0, 2).map((word) => [...word.replace(/^[^\p{L}\p{N}]+/u, "")][0] ?? "").join("");
1122
+ if (letters) return letters.toUpperCase();
1123
+ }
1124
+ }
1125
+ /**
1126
+ * What `person`'s avatar is drawn from (AERO-2704): their picture and its
1127
+ * source, and for a default, their seed, initials and the brand's own
1128
+ * default. Give the picture when its source is one the app shows, else the
1129
+ * app's own art from `seed` (`defaultPick` over the app's set), else
1130
+ * `defaultPicture`, else Synq's (`avatarSvg(seed)`).
1131
+ */
1132
+ function avatarOf(person) {
1133
+ const picture = person.picture || void 0;
1134
+ return {
1135
+ picture,
1136
+ source: person.picture_source || (picture ? "url" : "none"),
1137
+ seed: person.avatar_seed || person.sub || person.id || "",
1138
+ initials: initialsOf({
1139
+ name: person.name,
1140
+ username: person.preferred_username ?? person.username,
1141
+ email: person.email
1142
+ }),
1143
+ defaultPicture: person.default_picture || void 0
1144
+ };
1145
+ }
1146
+ /** A Solana address as people see one: base58, 32 to 44 characters. */
1147
+ const WALLET = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
1148
+ const escapeText = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1149
+ /**
1150
+ * The avatar as an SVG document: one square of the gradient, which its
1151
+ * container clips to a circle, and the highlight over it. `id` names its two
1152
+ * gradients (`<id>g`, `<id>h`), so two on one page never share one.
1153
+ */
1154
+ function avatarSvg(seed, options = {}) {
1155
+ const parts = avatarParts(seed, options);
1156
+ const id = (options.id ?? "av").replace(/[^A-Za-z0-9_-]/g, "");
1157
+ const text = parts.text ? `<text x="32" y="32" dy=".35em" text-anchor="middle" font-family="system-ui,sans-serif" font-size="26" font-weight="600" fill="#fff">${escapeText(parts.text)}</text>` : "";
1158
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true"><defs><linearGradient id="${id}g" gradientTransform="rotate(${parts.angle} .5 .5)"><stop offset="0" stop-color="${parts.from}"/><stop offset="1" stop-color="${parts.to}"/></linearGradient><radialGradient id="${id}h"><stop offset="0" stop-color="#fff" stop-opacity=".3"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs><rect width="64" height="64" fill="url(#${id}g)"/><circle cx="${parts.highlight.x}" cy="${parts.highlight.y}" r="26" fill="url(#${id}h)"/>${text}</svg>`;
1159
+ }
1160
+ //#endregion
1161
+ export { CHALLENGE_METHODS, CHALLENGE_PROBLEMS, ERROR_CODES, SIGN_IN_ENDINGS, SignInFlow, SynqError, accountApiUrl, avatarOf, avatarParts, avatarSvg, browserLocalStorage, browserSessionStorage, createAccountClient, createSynqClient, cyrb128, defaultPick, embeddedBrowser, initialsOf, isSynqError, linkSignIn, memoryStorage, problemError, readMethodList, signInEndingOf, signInMethods, signInMethodsUrl, solveEmailWork, walletConflict };