@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/README.md +7 -1
- package/dist/api-BGeXhXo6.js +913 -0
- package/dist/index.d.ts +3326 -1504
- package/dist/index.js +544 -11
- package/dist/management-C4LYIC3S.d.ts +20745 -0
- package/dist/server.d.ts +164 -3
- package/dist/server.js +140 -3
- package/package.json +1 -1
- package/dist/api-Cf9K1Rc9.js +0 -163
- package/dist/management-y9-9AIc-.d.ts +0 -11198
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a 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-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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 };
|