@synqauth/js 0.0.0 → 0.3.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 problemError, i as isSynqError, n as secureUrl, o as toSynqError, r as SynqError, t as createApiClient } from "./api-Cf9K1Rc9.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-BGeXhXo6.js";
2
2
  import * as oauth from "oauth4webapi";
3
3
  //#region src/storage.ts
4
4
  /**
@@ -159,6 +159,7 @@ var Client = class {
159
159
  redirectUri: this.#options.redirectUri,
160
160
  resource: options.resource ?? this.#options.resource,
161
161
  maxAge: options.maxAge,
162
+ ...options.acr?.essential ? { acr: [...options.acr.values] } : {},
162
163
  createdAt: now()
163
164
  };
164
165
  const url = new URL(server.authorization_endpoint);
@@ -181,6 +182,15 @@ var Client = class {
181
182
  if (options.maxAge !== void 0) params.set("max_age", String(options.maxAge));
182
183
  if (options.loginHint) params.set("login_hint", options.loginHint);
183
184
  if (options.method) params.set("synq_method", options.method);
185
+ if (options.acr) {
186
+ if (options.acr.values.length === 0) throw new SynqError("invalid_options", "acr needs at least one value");
187
+ if (params.has("acr_values") || params.has("claims")) throw new SynqError("invalid_options", "acr_values and claims come from the acr option");
188
+ if (options.acr.essential) params.set("claims", JSON.stringify({ id_token: { acr: {
189
+ essential: true,
190
+ values: [...options.acr.values]
191
+ } } }));
192
+ else params.set("acr_values", options.acr.values.join(" "));
193
+ }
184
194
  await this.#transactions.set(TRANSACTION_PREFIX + transaction.state, JSON.stringify(transaction));
185
195
  if (!options.pushed) return url;
186
196
  if (!server.pushed_authorization_request_endpoint) throw new SynqError("discovery_failed", "The issuer takes no pushed authorization requests");
@@ -224,6 +234,7 @@ var Client = class {
224
234
  requireIdToken: true
225
235
  });
226
236
  const claims = oauth.getValidatedIdTokenClaims(result);
237
+ if (transaction.acr && !(claims?.acr && transaction.acr.includes(claims.acr))) throw new SynqError("unmet_authentication_requirements", "The sign-in did not reach the level this app requires");
227
238
  this.#generation += 1;
228
239
  await this.#clear();
229
240
  const session = await this.#save(result, claims);
@@ -271,8 +282,42 @@ var Client = class {
271
282
  this.#listeners.delete(listener);
272
283
  };
273
284
  }
285
+ startSignIn({ endUserIp, ...options } = {}) {
286
+ return new SignInFlow(this.#signInPort(endUserIp), newSignIn(options.scope ?? this.#options.scope ?? "openid", {
287
+ ...options,
288
+ resource: options.resource ?? this.#options.resource
289
+ }));
290
+ }
291
+ resumeSignIn(saved, options = {}) {
292
+ return new SignInFlow(this.#signInPort(options.endUserIp), readSavedSignIn(saved));
293
+ }
294
+ /** What a flow needs of this client: its issuer, its credentials, and keeping the session. */
295
+ #signInPort(endUserIp) {
296
+ return {
297
+ ...endUserIp ? { headers: { "synq-end-user-ip": endUserIp } } : {},
298
+ server: () => this.#discover(),
299
+ client: this.#client,
300
+ auth: this.#auth,
301
+ fetch: this.#options.fetch ?? globalThis.fetch.bind(globalThis),
302
+ finish: async (result, claims) => {
303
+ this.#generation += 1;
304
+ await this.#clear();
305
+ const session = await this.#save(result, claims, void 0, "app");
306
+ this.#emit({
307
+ type: "signed-in",
308
+ session
309
+ });
310
+ return session;
311
+ },
312
+ hosted: (options) => this.signIn({
313
+ ...options.method ? { method: options.method } : {},
314
+ ...options.loginHint ? { loginHint: options.loginHint } : {}
315
+ })
316
+ };
317
+ }
274
318
  async signOut(options = {}) {
275
319
  const stored = await this.#load();
320
+ if (options.stay) await this.#endBrandSession(stored);
276
321
  const refreshToken = await this.#readRefreshToken();
277
322
  this.#generation += 1;
278
323
  await this.#clear();
@@ -291,7 +336,7 @@ var Client = class {
291
336
  } catch (error) {
292
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);
293
338
  }
294
- if (!server.end_session_endpoint) return null;
339
+ if (options.stay || !server.end_session_endpoint || stored?.via === "app") return null;
295
340
  const url = new URL(server.end_session_endpoint);
296
341
  url.searchParams.set("client_id", this.#options.clientId);
297
342
  if (stored?.idToken) url.searchParams.set("id_token_hint", stored.idToken);
@@ -378,6 +423,48 @@ var Client = class {
378
423
  });
379
424
  return session;
380
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
+ }
381
468
  #discover() {
382
469
  this.#server ??= (async () => {
383
470
  try {
@@ -394,14 +481,15 @@ var Client = class {
394
481
  })();
395
482
  return this.#server;
396
483
  }
397
- async #save(result, claims, previous) {
484
+ async #save(result, claims, previous, via = previous?.via ?? "hosted") {
398
485
  const stored = {
399
486
  accessToken: result.access_token,
400
487
  tokenType: result.token_type,
401
488
  expiresAt: result.expires_in === void 0 ? void 0 : now() + result.expires_in,
402
489
  scope: result.scope ?? previous?.scope,
403
490
  idToken: result.id_token ?? previous?.idToken,
404
- claims
491
+ claims,
492
+ via
405
493
  };
406
494
  this.#session = stored;
407
495
  await this.#storage.set(SESSION_KEY, JSON.stringify(stored));
@@ -432,6 +520,7 @@ var Client = class {
432
520
  async #public(stored) {
433
521
  return {
434
522
  ...stored,
523
+ via: stored.via ?? "hosted",
435
524
  refreshable: await this.#readRefreshToken() !== void 0
436
525
  };
437
526
  }
@@ -471,11 +560,12 @@ function accountApiUrl(issuer) {
471
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. */
472
561
  function createAccountClient(options) {
473
562
  if (typeof options.accessToken !== "function") throw new SynqError("invalid_options", "Give the account client an accessToken function");
474
- const raw = createApiClient({
563
+ const api = createApiClient({
475
564
  baseUrl: accountApiUrl(options.issuer),
476
565
  credential: options.accessToken,
477
566
  fetch: options.fetch
478
567
  });
568
+ const raw = api;
479
569
  async function run(send, pick) {
480
570
  try {
481
571
  const json = await send();
@@ -494,18 +584,89 @@ function createAccountClient(options) {
494
584
  ...request,
495
585
  signal: opts.signal
496
586
  }));
497
- 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
+ });
498
596
  const change = (method, path, opts = {}, request = {}) => run(() => raw[method](path, {
499
597
  ...request,
500
598
  idempotencyKey: opts.idempotencyKey ?? crypto.randomUUID(),
501
599
  signal: opts.signal
502
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
+ }
503
659
  return {
504
660
  me: (opts) => read("/v1/profile", opts),
505
661
  user: {
506
662
  update: (changes, opts) => change("patch", "/v1/profile", opts, { body: changes }),
507
663
  metadata: (opts) => read("/v1/metadata", opts),
508
- 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
+ }
509
670
  },
510
671
  wallets: {
511
672
  list: (opts) => list("/v1/wallets", opts),
@@ -513,11 +674,14 @@ function createAccountClient(options) {
513
674
  path: { wallet },
514
675
  body: changes
515
676
  }),
516
- 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 } })
517
680
  },
518
681
  accounts: {
519
682
  list: (opts) => list("/v1/identities", opts),
520
- link: (method, opts) => change("post", "/v1/link-requests", opts, { body: { method } }),
683
+ linkable: (opts) => list("/v1/link-methods", opts),
684
+ link: linkWay,
521
685
  unlink: (identity, opts) => change("delete", "/v1/identities/{identity}", opts, { path: { identity } })
522
686
  },
523
687
  sessions: {
@@ -534,6 +698,12 @@ function createAccountClient(options) {
534
698
  list: (opts) => list("/v1/consents", opts),
535
699
  withdraw: (consent, opts) => change("delete", "/v1/consents/{consent}", opts, { path: { consent } })
536
700
  },
701
+ apiKeys: {
702
+ apis: (opts) => list("/v1/apis", opts),
703
+ list: (opts) => list("/v1/api-keys", opts),
704
+ create: (input, opts) => change("post", "/v1/api-keys", opts, { body: input }),
705
+ revoke: (key, opts) => change("delete", "/v1/api-keys/{key}", opts, { path: { key } })
706
+ },
537
707
  activity: (opts) => list("/v1/sign-ins", opts),
538
708
  deletion: {
539
709
  schedule: (opts) => change("post", "/v1/deletion", opts),
@@ -541,6 +711,146 @@ function createAccountClient(options) {
541
711
  }
542
712
  };
543
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
+ }
729
+ //#endregion
730
+ //#region src/generated/challenge.ts
731
+ /** The ways in an app's own screens sign people in with (`synq_method`). */
732
+ const CHALLENGE_METHODS = [
733
+ "password",
734
+ "email",
735
+ "passkey",
736
+ "wallet",
737
+ "test"
738
+ ];
739
+ /** What went wrong with a step (`synq_problem`): branch on it, never on the English. */
740
+ const CHALLENGE_PROBLEMS = [
741
+ "missing",
742
+ "invalid_email",
743
+ "wrong_credentials",
744
+ "email_taken",
745
+ "password_too_short",
746
+ "password_too_long",
747
+ "password_guessable",
748
+ "password_breached",
749
+ "wrong_code",
750
+ "code_expired",
751
+ "code_used_up",
752
+ "address_suppressed",
753
+ "team_only",
754
+ "send_failed",
755
+ "work_required",
756
+ "passkey_failed",
757
+ "passkey_unknown",
758
+ "passkey_expired",
759
+ "wallet_failed",
760
+ "wallet_expired",
761
+ "slow_down",
762
+ "locked",
763
+ "limited"
764
+ ];
765
+ //#endregion
766
+ //#region src/sign-in-methods.ts
767
+ /** Where the issuer lists an app's sign-in methods. */
768
+ function signInMethodsUrl(issuer, clientId) {
769
+ const base = secureUrl(issuer, "The issuer");
770
+ const url = new URL(`${base.href.replace(/\/+$/, "")}/sign-in/methods`);
771
+ url.searchParams.set("client_id", clientId);
772
+ return url;
773
+ }
774
+ /**
775
+ * The ways `clientId`'s sign-in offers at `issuer`, in the brand's order:
776
+ * public, so it works for someone signed out, from a server or the app's own
777
+ * pages (the issuer lets the origins of the app's redirect URIs read it).
778
+ * The issuer lets it be kept a minute. Throws a `SynqError`: the issuer's
779
+ * `invalid_client` for an app it doesn't know, `network_error`, or
780
+ * `invalid_response`.
781
+ */
782
+ async function signInMethods(options) {
783
+ if (!options.clientId) throw new SynqError("invalid_options", "The clientId is missing");
784
+ const url = signInMethodsUrl(options.issuer, options.clientId);
785
+ let response;
786
+ try {
787
+ response = await (options.fetch ?? fetch)(url, {
788
+ headers: { accept: "application/json" },
789
+ ...options.signal ? { signal: options.signal } : {}
790
+ });
791
+ } catch (error) {
792
+ throw toSynqError(error);
793
+ }
794
+ let body;
795
+ try {
796
+ body = await response.json();
797
+ } catch (error) {
798
+ throw new SynqError("invalid_response", "Synq answered the sign-in methods with no JSON", {
799
+ status: response.status,
800
+ cause: error
801
+ });
802
+ }
803
+ if (!response.ok) {
804
+ const { error, error_description: description } = body ?? {};
805
+ const code = typeof error === "string" ? error : "invalid_response";
806
+ const detail = typeof description === "string" ? description : void 0;
807
+ throw new SynqError(code, detail ?? code, {
808
+ status: response.status,
809
+ description: detail
810
+ });
811
+ }
812
+ return readMethodList(body);
813
+ }
814
+ /** `body` as a method list, checked: what the issuer and any route mount answer. */
815
+ function readMethodList(body) {
816
+ const list = body;
817
+ 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");
818
+ return {
819
+ methods: list.methods,
820
+ show_last_used: list.show_last_used
821
+ };
822
+ }
823
+ /** Marks the apps people open links from add to their built-in browser's user agent. */
824
+ const IN_APP_MARKS = [
825
+ /\bTwitter(?:Android)?\b/i,
826
+ /\bLinkedInApp\b/i,
827
+ /\bSlack(?:_SSB)?\//i,
828
+ /\bInstagram\b/i,
829
+ /\bFB(?:AN|AV|_IAB)\b/,
830
+ /\bmusical_ly|\bTikTok\b|\bBytedanceWebview\b/i,
831
+ /\bSnapchat\b/i,
832
+ /\bLine\//,
833
+ /\bMicroMessenger\b/i
834
+ ];
835
+ /**
836
+ * Whether the page is in another app's built-in browser, where some ways in
837
+ * fail: Google refuses to sign anyone in there (`disallowed_useragent`),
838
+ * and passkeys usually can't be reached. `android-webview` for an Android
839
+ * app's web view, `ios-in-app` for an iPhone or iPad app's, null for a real
840
+ * browser (or when unsure). Hide those methods there, or offer to open the
841
+ * page in the browser. Reads `userAgent`, the browser's own by default; a
842
+ * server passes the request's.
843
+ */
844
+ function embeddedBrowser(userAgent) {
845
+ const agent = userAgent ?? (typeof navigator === "undefined" ? void 0 : navigator.userAgent) ?? "";
846
+ if (!agent) return null;
847
+ const ios = /\b(iPhone|iPad|iPod)\b/.test(agent);
848
+ const android = /\bAndroid\b/.test(agent);
849
+ if (android && /;\s*wv\)/.test(agent)) return "android-webview";
850
+ if (IN_APP_MARKS.some((mark) => mark.test(agent))) return ios ? "ios-in-app" : android ? "android-webview" : null;
851
+ if (ios && /AppleWebKit\//.test(agent) && !/\bSafari\//.test(agent)) return "ios-in-app";
852
+ return null;
853
+ }
544
854
  //#endregion
545
855
  //#region src/generated/errors.ts
546
856
  /** Each `code` a Synq API's problem documents carry, with its HTTP status. */
@@ -556,6 +866,7 @@ const ERROR_CODES = {
556
866
  idempotency_key_conflict: 422,
557
867
  idempotency_key_in_flight: 409,
558
868
  conflict: 409,
869
+ ambiguous_name: 409,
559
870
  org_slug_taken: 409,
560
871
  issuer_slug_taken: 409,
561
872
  issuer_slug_immutable: 422,
@@ -571,7 +882,13 @@ const ERROR_CODES = {
571
882
  passkey_exists: 409,
572
883
  sign_in_method_unavailable: 409,
573
884
  identity_limit_reached: 409,
885
+ wallet_in_use: 409,
886
+ wallet_signature_rejected: 400,
574
887
  connection_rejected: 422,
888
+ own_app_required: 422,
889
+ scope_not_allowed: 422,
890
+ scope_unavailable: 422,
891
+ scope_needs_own_app: 422,
575
892
  token_gates_unavailable: 409,
576
893
  passkey_rejected: 400,
577
894
  email_undeliverable: 422,
@@ -584,6 +901,7 @@ const ERROR_CODES = {
584
901
  no_billing_account: 409,
585
902
  no_subscription: 409,
586
903
  payment_failed: 402,
904
+ trial_unavailable: 409,
587
905
  custom_domain_exists: 409,
588
906
  hostname_taken: 409,
589
907
  invalid_hostname: 422,
@@ -597,9 +915,224 @@ const ERROR_CODES = {
597
915
  upload_expired: 410,
598
916
  file_rejected: 422,
599
917
  upload_limit_reached: 409,
918
+ picture_unavailable: 409,
919
+ api_keys_not_allowed: 403,
920
+ api_key_limit_reached: 409,
600
921
  rate_limited: 429,
922
+ method_not_allowed: 405,
601
923
  internal_error: 500,
602
- service_unavailable: 503
924
+ service_unavailable: 503,
925
+ storage_unavailable: 503
603
926
  };
604
927
  //#endregion
605
- export { ERROR_CODES, SynqError, accountApiUrl, browserLocalStorage, browserSessionStorage, createAccountClient, createSynqClient, isSynqError, linkSignIn, memoryStorage, problemError };
928
+ //#region src/avatar.ts
929
+ /** The four 32-bit words of cyrb128 over `seed`. */
930
+ function cyrb128(seed) {
931
+ let h1 = 1779033703;
932
+ let h2 = 3144134277;
933
+ let h3 = 1013904242;
934
+ let h4 = 2773480762;
935
+ for (let i = 0; i < seed.length; i++) {
936
+ const k = seed.charCodeAt(i);
937
+ h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
938
+ h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
939
+ h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
940
+ h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
941
+ }
942
+ h1 = Math.imul(h3 ^ h1 >>> 18, 597399067);
943
+ h2 = Math.imul(h4 ^ h2 >>> 22, 2869860233);
944
+ h3 = Math.imul(h1 ^ h3 >>> 17, 951274213);
945
+ h4 = Math.imul(h2 ^ h4 >>> 19, 2716044179);
946
+ h1 ^= h2 ^ h3 ^ h4;
947
+ h2 ^= h1;
948
+ h3 ^= h1;
949
+ h4 ^= h1;
950
+ return [
951
+ h1 >>> 0,
952
+ h2 >>> 0,
953
+ h3 >>> 0,
954
+ h4 >>> 0
955
+ ];
956
+ }
957
+ /**
958
+ * Which of a set of default pictures is the person's: of `set`, the one
959
+ * whose id (`idOf`, the item itself for strings: a file id, a name, a URL
960
+ * that never changes) scores highest with their seed (rendezvous hashing).
961
+ * The same seed always picks the same one, and the set can grow and shrink
962
+ * around it: adding one to a set of n moves only about 1 in n + 1 people,
963
+ * to the new one, and removing one moves only the people who had it.
964
+ * Undefined for an empty set.
965
+ */
966
+ function defaultPick(seed, set, idOf = String) {
967
+ let best;
968
+ let high = -1;
969
+ let low = -1;
970
+ for (const item of set) {
971
+ const [w0, w1] = cyrb128(`${seed}:${idOf(item)}`);
972
+ if (w0 > high || w0 === high && w1 > low) {
973
+ best = item;
974
+ high = w0;
975
+ low = w1;
976
+ }
977
+ }
978
+ return best;
979
+ }
980
+ /** sRGB, 0 to 1 per channel, of OKLCH `l c h` (h in degrees), unclipped. */
981
+ function oklchToLinear(l, c, h) {
982
+ const rad = h * Math.PI / 180;
983
+ const a = c * Math.cos(rad);
984
+ const b = c * Math.sin(rad);
985
+ const l_ = (l + .3963377774 * a + .2158037573 * b) ** 3;
986
+ const m_ = (l - .1055613458 * a - .0638541728 * b) ** 3;
987
+ const s_ = (l - .0894841775 * a - 1.291485548 * b) ** 3;
988
+ return [
989
+ 4.0767416621 * l_ - 3.3077115913 * m_ + .2309699292 * s_,
990
+ -1.2684380046 * l_ + 2.6097574011 * m_ - .3413193965 * s_,
991
+ -.0041960863 * l_ - .7034186147 * m_ + 1.707614701 * s_
992
+ ];
993
+ }
994
+ const inGamut = (rgb) => rgb.every((v) => v >= -1e-6 && v <= 1 + 1e-6);
995
+ const encode = (v) => {
996
+ const x = Math.min(1, Math.max(0, v));
997
+ const e = x <= .0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - .055;
998
+ return Math.round(e * 255);
999
+ };
1000
+ const decode = (byte) => {
1001
+ const x = byte / 255;
1002
+ return x <= .04045 ? x / 12.92 : ((x + .055) / 1.055) ** 2.4;
1003
+ };
1004
+ /** OKLCH as an sRGB byte triple, its chroma cut back until it fits sRGB. */
1005
+ function oklchBytes(l, c, h) {
1006
+ let rgb = oklchToLinear(l, c, h);
1007
+ if (!inGamut(rgb)) {
1008
+ let low = 0;
1009
+ let high = c;
1010
+ for (let i = 0; i < 24; i++) {
1011
+ const mid = (low + high) / 2;
1012
+ if (inGamut(oklchToLinear(l, mid, h))) low = mid;
1013
+ else high = mid;
1014
+ }
1015
+ rgb = oklchToLinear(l, low, h);
1016
+ }
1017
+ return [
1018
+ encode(rgb[0]),
1019
+ encode(rgb[1]),
1020
+ encode(rgb[2])
1021
+ ];
1022
+ }
1023
+ const hex = (bytes) => `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
1024
+ /** WCAG's contrast of white on `bytes`. */
1025
+ function whiteContrast(bytes) {
1026
+ const [r, g, b] = bytes.map(decode);
1027
+ return 1.05 / (.2126 * r + .7152 * g + .0722 * b + .05);
1028
+ }
1029
+ /** OKLCH as hex, darkened in steps of 0.01 until white on it is at least 4.5:1 when `forWhite`. */
1030
+ function color(l, c, h, forWhite) {
1031
+ let lightness = l;
1032
+ let bytes = oklchBytes(lightness, c, h);
1033
+ while (forWhite && whiteContrast(bytes) < 4.5 && lightness > .2) {
1034
+ lightness = Math.round((lightness - .01) * 100) / 100;
1035
+ bytes = oklchBytes(lightness, c, h);
1036
+ }
1037
+ return hex(bytes);
1038
+ }
1039
+ /**
1040
+ * How much a hue is in the band where a dark color turns olive or brown
1041
+ * (yellows, 55° to 125° in OKLCH): 1 at its middle, 0 outside it.
1042
+ */
1043
+ function muddiness(hue) {
1044
+ const off = Math.abs((hue - 90 + 540) % 360 - 180);
1045
+ return off >= 35 ? 0 : Math.cos(off / 35 * (Math.PI / 2)) ** 2;
1046
+ }
1047
+ /**
1048
+ * A monogram's hue: the circle without that band, spread evenly over the
1049
+ * rest, since white text needs a darkness that band can't have and stay
1050
+ * clean.
1051
+ */
1052
+ function monogramHue(hue) {
1053
+ const spread = hue * 290 / 360;
1054
+ return Math.round(spread < 55 ? spread : spread + 70) % 360;
1055
+ }
1056
+ /**
1057
+ * What the avatar for `seed` is drawn from: its two colors, angle,
1058
+ * highlight and, for a monogram, its initials. `avatarSvg` draws it; draw
1059
+ * it yourself from these to match it in another renderer.
1060
+ */
1061
+ function avatarParts(seed, options = {}) {
1062
+ const [w0, w1, w2, w3] = cyrb128(seed);
1063
+ const h1 = w0 % 360;
1064
+ const h2 = (h1 + 40 + w1 % 80) % 360;
1065
+ const text = options.style === "monogram" ? cleanInitials(options.initials) : void 0;
1066
+ const monogram = text !== void 0;
1067
+ const lift = Math.round(muddiness(h2) * 12) / 100;
1068
+ const m1 = monogramHue(h1);
1069
+ const step = 25 + w1 % 45;
1070
+ const near = (m1 + step) % 360;
1071
+ const m2 = near >= 55 && near < 125 ? (m1 - step + 360) % 360 : near;
1072
+ return {
1073
+ from: monogram ? color(.55, .12, m1, true) : color(.82, .1, h1, false),
1074
+ to: monogram ? color(.45, .12, m2, true) : color(.62 + lift, .14, h2, false),
1075
+ angle: w2 % 360,
1076
+ highlight: {
1077
+ x: 16 + w3 % 32,
1078
+ y: 16 + (w3 >>> 5) % 32
1079
+ },
1080
+ ...monogram ? { text } : {}
1081
+ };
1082
+ }
1083
+ /** At most two letters, uppercase, or undefined for none. */
1084
+ function cleanInitials(initials) {
1085
+ const letters = [...(initials ?? "").replace(/[\s\p{C}]/gu, "")].slice(0, 2).join("");
1086
+ return letters ? letters.toUpperCase() : void 0;
1087
+ }
1088
+ /** Up to two initials of a name, a username or an email address's local part; never of a wallet address. */
1089
+ function initialsOf(person) {
1090
+ const candidates = [
1091
+ person.name,
1092
+ person.username,
1093
+ person.email?.split("@")[0]
1094
+ ];
1095
+ for (const candidate of candidates) {
1096
+ const text = candidate?.trim();
1097
+ if (!text || WALLET.test(text)) continue;
1098
+ 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("");
1099
+ if (letters) return letters.toUpperCase();
1100
+ }
1101
+ }
1102
+ /**
1103
+ * What `person`'s avatar is drawn from (AERO-2704): their picture and its
1104
+ * source, and for a default, their seed, initials and the brand's own
1105
+ * default. Give the picture when its source is one the app shows, else the
1106
+ * app's own art from `seed` (`defaultPick` over the app's set), else
1107
+ * `defaultPicture`, else Synq's (`avatarSvg(seed)`).
1108
+ */
1109
+ function avatarOf(person) {
1110
+ const picture = person.picture || void 0;
1111
+ return {
1112
+ picture,
1113
+ source: person.picture_source || (picture ? "url" : "none"),
1114
+ seed: person.avatar_seed || person.sub || person.id || "",
1115
+ initials: initialsOf({
1116
+ name: person.name,
1117
+ username: person.preferred_username ?? person.username,
1118
+ email: person.email
1119
+ }),
1120
+ defaultPicture: person.default_picture || void 0
1121
+ };
1122
+ }
1123
+ /** A Solana address as people see one: base58, 32 to 44 characters. */
1124
+ const WALLET = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
1125
+ const escapeText = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1126
+ /**
1127
+ * The avatar as an SVG document: one square of the gradient, which its
1128
+ * container clips to a circle, and the highlight over it. `id` names its two
1129
+ * gradients (`<id>g`, `<id>h`), so two on one page never share one.
1130
+ */
1131
+ function avatarSvg(seed, options = {}) {
1132
+ const parts = avatarParts(seed, options);
1133
+ const id = (options.id ?? "av").replace(/[^A-Za-z0-9_-]/g, "");
1134
+ 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>` : "";
1135
+ 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>`;
1136
+ }
1137
+ //#endregion
1138
+ export { CHALLENGE_METHODS, CHALLENGE_PROBLEMS, ERROR_CODES, SignInFlow, SynqError, accountApiUrl, avatarOf, avatarParts, avatarSvg, browserLocalStorage, browserSessionStorage, createAccountClient, createSynqClient, cyrb128, defaultPick, embeddedBrowser, initialsOf, isSynqError, linkSignIn, memoryStorage, problemError, readMethodList, signInMethods, signInMethodsUrl, solveEmailWork, walletConflict };