@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/README.md +7 -1
- package/dist/api-D2jDzTRO.js +913 -0
- package/dist/index.d.ts +3208 -1536
- package/dist/index.js +481 -10
- package/dist/management-BN4KI1ZH.d.ts +27186 -0
- package/dist/server.d.ts +58 -3
- package/dist/server.js +21 -4
- package/package.json +1 -1
- package/dist/api-CeYAjkvX.js +0 -467
- package/dist/management-CWdCiiEv.d.ts +0 -11856
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as readSavedSignIn, c as
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 };
|