@synqauth/nextjs 0.2.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.
@@ -1,32 +1,38 @@
1
- import { SynqError, createSynqClient, isSynqError, memoryStorage } from "@synqauth/js";
1
+ import { SynqError, createSynqClient, isSynqError, memoryStorage, readMethodList, signInMethods } from "@synqauth/js";
2
2
  //#region src/crypto.ts
3
3
  const encoder = new TextEncoder();
4
4
  const decoder = new TextDecoder();
5
5
  const VERSION = "v1";
6
6
  const keys = /* @__PURE__ */ new Map();
7
- /** The sealing key for a session secret, derived once per secret. */
8
- function sealingKey(secret) {
7
+ /**
8
+ * The sealing key for a session secret, derived once per secret; with
9
+ * `site`, the key for that site alone (`siteBinding`), which the site's core
10
+ * keeps rather than this module.
11
+ */
12
+ function sealingKey(secret, site = "") {
9
13
  if (encoder.encode(secret).length < 32) throw new TypeError(`The session secret must be at least 32 bytes`);
14
+ if (site) return deriveKey(secret, `${VERSION}\nsite ${site}`);
10
15
  let key = keys.get(secret);
11
16
  if (!key) {
12
- key = (async () => {
13
- const base = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
14
- return crypto.subtle.deriveKey({
15
- name: "HKDF",
16
- hash: "SHA-256",
17
- salt: encoder.encode("@synqauth/nextjs session"),
18
- info: encoder.encode(VERSION)
19
- }, base, {
20
- name: "AES-GCM",
21
- length: 256
22
- }, false, ["encrypt", "decrypt"]);
23
- })();
17
+ key = deriveKey(secret, VERSION);
24
18
  keys.set(secret, key);
25
19
  }
26
20
  return key;
27
21
  }
28
- async function sealingKeys(current, previous = []) {
29
- const open = await Promise.all([current, ...previous].map((secret) => sealingKey(secret)));
22
+ async function deriveKey(secret, info) {
23
+ const base = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
24
+ return crypto.subtle.deriveKey({
25
+ name: "HKDF",
26
+ hash: "SHA-256",
27
+ salt: encoder.encode("@synqauth/nextjs session"),
28
+ info: encoder.encode(info)
29
+ }, base, {
30
+ name: "AES-GCM",
31
+ length: 256
32
+ }, false, ["encrypt", "decrypt"]);
33
+ }
34
+ async function sealingKeys(current, previous = [], site = "") {
35
+ const open = await Promise.all([current, ...previous].map((secret) => sealingKey(secret, site)));
30
36
  return {
31
37
  seal: open[0],
32
38
  open
@@ -86,18 +92,82 @@ const MULTI_PROCESS = [
86
92
  ];
87
93
  /** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
88
94
  function resolveConfig(options = {}, env = processEnv()) {
89
- const issuer = required(options.issuer ?? env.SYNQ_ISSUER, "SYNQ_ISSUER").replace(/\/+$/, "");
90
- checkUrl(issuer, "SYNQ_ISSUER");
91
- const clientId = required(options.clientId ?? env.SYNQ_CLIENT_ID, "SYNQ_CLIENT_ID");
92
- const clientSecret = required(options.clientSecret ?? env.SYNQ_CLIENT_SECRET, "SYNQ_CLIENT_SECRET");
93
- const appUrl = checkUrl(required(options.appUrl ?? env.SYNQ_APP_URL, "SYNQ_APP_URL"), "SYNQ_APP_URL");
95
+ if (options.resolve) throw new SynqError("invalid_options", "This configuration has resolve, so each request picks its own: synqAuth() and synqProxy() read it per request");
96
+ if (options.hosts !== void 0) throw new SynqError("invalid_options", "hosts goes with resolve: the hosts it answers for");
97
+ return build(options, {
98
+ issuer: options.issuer ?? env.SYNQ_ISSUER,
99
+ clientId: options.clientId ?? env.SYNQ_CLIENT_ID,
100
+ clientSecret: options.clientSecret ?? env.SYNQ_CLIENT_SECRET,
101
+ appUrl: options.appUrl ?? env.SYNQ_APP_URL,
102
+ account: options.account,
103
+ postLogoutRedirectUri: options.postLogoutRedirectUri,
104
+ storePrefix: void 0
105
+ }, {
106
+ issuer: "SYNQ_ISSUER",
107
+ clientId: "SYNQ_CLIENT_ID",
108
+ clientSecret: "SYNQ_CLIENT_SECRET",
109
+ appUrl: "SYNQ_APP_URL"
110
+ }, env, false);
111
+ }
112
+ /** The options `resolve` answers for each site, which the top level never gives alongside it. */
113
+ const SITE_OPTIONS = [
114
+ "issuer",
115
+ "clientId",
116
+ "clientSecret",
117
+ "appUrl",
118
+ "postLogoutRedirectUri"
119
+ ];
120
+ /** A site's prefix in the store: letters, digits and `.:_~-`, as a key-value store's key takes them. */
121
+ const STORE_PREFIX = /^[A-Za-z0-9._:~-]{1,128}$/;
122
+ /**
123
+ * One site's configuration (AERO-2655): what `resolve` answered for
124
+ * `request`, over `options`' shared settings. Throws `invalid_options` for
125
+ * anything missing or unsafe, naming the field, never its value.
126
+ */
127
+ function siteConfig(options, site, request, env = processEnv()) {
128
+ const given = SITE_OPTIONS.filter((name) => options[name] !== void 0);
129
+ if (given.length > 0) throw new SynqError("invalid_options", `With resolve, each site's ${given.join(", ")} come from resolve: take ${given.length === 1 ? "it" : "them"} out of the options`);
130
+ if (site === null || typeof site !== "object") throw new SynqError("invalid_options", "resolve answers a site, or null for a host the app doesn’t serve");
131
+ const config = build(options, {
132
+ issuer: text(site.issuer, "resolve()'s issuer"),
133
+ clientId: text(site.clientId, "resolve()'s clientId"),
134
+ clientSecret: text(site.clientSecret, "resolve()'s clientSecret"),
135
+ appUrl: site.appUrl === void 0 ? request.origin : text(site.appUrl, "resolve()'s appUrl"),
136
+ account: site.account ?? options.account,
137
+ postLogoutRedirectUri: site.postLogoutRedirectUri === void 0 ? void 0 : text(site.postLogoutRedirectUri, "resolve()'s postLogoutRedirectUri"),
138
+ storePrefix: site.storePrefix
139
+ }, {
140
+ issuer: "resolve()'s issuer",
141
+ clientId: "resolve()'s clientId",
142
+ clientSecret: "resolve()'s clientSecret",
143
+ appUrl: "resolve()'s appUrl"
144
+ }, env, true);
145
+ if (config.appUrl.origin !== request.origin) throw new SynqError("invalid_options", "resolve()'s appUrl must be the origin the request came to (its https://<host>): leave it out to use that");
146
+ return config;
147
+ }
148
+ /** A site's field: a string, or the error that names it. */
149
+ function text(value, name) {
150
+ if (value === void 0 || value === null || value === "") return void 0;
151
+ if (typeof value !== "string") throw new SynqError("invalid_options", `${name} must be a string`);
152
+ return value;
153
+ }
154
+ function build(options, fields, names, env, perSite) {
155
+ const issuer = required(fields.issuer, names.issuer).replace(/\/+$/, "");
156
+ checkUrl(issuer, names.issuer);
157
+ const clientId = required(fields.clientId, names.clientId);
158
+ const clientSecret = required(fields.clientSecret, names.clientSecret);
159
+ const appUrl = checkUrl(required(fields.appUrl, names.appUrl), names.appUrl);
94
160
  const sessionSecret = required(options.sessionSecret ?? env.SYNQ_SESSION_SECRET, "SYNQ_SESSION_SECRET");
95
161
  const previousSessionSecrets = options.previousSessionSecrets ?? (env.SYNQ_SESSION_SECRET_PREVIOUS ?? "").split(",").map((secret) => secret.trim()).filter(Boolean);
96
162
  for (const secret of [sessionSecret, ...previousSessionSecrets]) if (new TextEncoder().encode(secret).length < 32) throw new SynqError("invalid_options", `Each session secret must be at least 32 bytes: make one with \`openssl rand -base64 32\``);
97
163
  const hint = MULTI_PROCESS.find((name) => env[name]);
98
164
  if (!options.store && !options.allowSingleProcessCookieMode && hint) throw new SynqError("invalid_options", `${hint} is set, so this app may run on more than one server process, where sessions in cookies can't refresh one at a time and Synq would sign people out: give synqAuth() a store every process shares (keyValueSessionStore over Redis or Valkey), or allowSingleProcessCookieMode if it truly runs one`);
165
+ if (fields.storePrefix !== void 0) {
166
+ if (typeof fields.storePrefix !== "string" || !STORE_PREFIX.test(fields.storePrefix)) throw new SynqError("invalid_options", "resolve()'s storePrefix is 1 to 128 of A-Z a-z 0-9 . : _ ~ -");
167
+ if (!options.store) throw new SynqError("invalid_options", "resolve()'s storePrefix places a site's sessions in synqAuth()'s store: give it a store");
168
+ }
99
169
  const basePath = normalizePath(options.basePath ?? "/api/auth");
100
- const accountApi = options.account === false ? void 0 : `${issuer}/me`;
170
+ const accountApi = fields.account === false ? void 0 : `${issuer}/me`;
101
171
  const primary = options.resource ?? accountApi;
102
172
  const scopes = /* @__PURE__ */ new Set([
103
173
  "openid",
@@ -120,14 +190,28 @@ function resolveConfig(options = {}, env = processEnv()) {
120
190
  accountApi,
121
191
  scope: [...scopes].join(" "),
122
192
  redirectUri: `${origin}${basePath}/callback`,
123
- postLogoutRedirectUri: options.postLogoutRedirectUri ?? `${origin}/`,
124
- store: options.store,
193
+ postLogoutRedirectUri: fields.postLogoutRedirectUri ?? `${origin}/`,
194
+ store: options.store && fields.storePrefix ? prefixedStore(options.store, fields.storePrefix) : options.store,
125
195
  sessionMaxAge: options.sessionMaxAge ?? 2592e3,
126
196
  refreshLeewaySeconds: options.refreshLeewaySeconds ?? 30,
127
197
  plugins: options.plugins ?? [],
128
198
  fetch: options.fetch,
129
199
  clientAddress: options.clientAddress ?? ((request) => request.headers.get("x-real-ip")?.trim() || void 0),
130
- secure: appUrl.protocol === "https:"
200
+ secure: appUrl.protocol === "https:",
201
+ site: perSite ? JSON.stringify([
202
+ issuer,
203
+ clientId,
204
+ origin
205
+ ]) : ""
206
+ };
207
+ }
208
+ /** `store` with every session id under `prefix`: one site's sessions, apart from the others'. */
209
+ function prefixedStore(store, prefix) {
210
+ return {
211
+ get: (id) => store.get(`${prefix}${id}`),
212
+ set: (id, values, maxAgeSeconds) => store.set(`${prefix}${id}`, values, maxAgeSeconds),
213
+ delete: (id) => store.delete(`${prefix}${id}`),
214
+ lock: (id, fn) => store.lock(`${prefix}${id}`, fn)
131
215
  };
132
216
  }
133
217
  function processEnv() {
@@ -156,6 +240,16 @@ function normalizePath(path) {
156
240
  return trimmed;
157
241
  }
158
242
  //#endregion
243
+ //#region src/intent.ts
244
+ /** The hosted flow an app asks for after identifying the account. */
245
+ const SIGN_IN_INTENTS = /* @__PURE__ */ new Set(["add-passkey", "verify-email"]);
246
+ /** Verification keeps the issuer session; adding a credential still needs a fresh sign-in. */
247
+ function intentPrompt(intent, prompt) {
248
+ if (prompt != null) return prompt;
249
+ if (intent === "verify-email") return "select_account";
250
+ return prompt ?? (intent ? "login" : void 0);
251
+ }
252
+ //#endregion
159
253
  //#region src/cookies.ts
160
254
  /** Characters per chunk, under the 4096 bytes a browser keeps for one cookie with its attributes. */
161
255
  const CHUNK_SIZE = 3800;
@@ -267,6 +361,52 @@ const RESERVED = /* @__PURE__ */ new Set([
267
361
  "link"
268
362
  ]);
269
363
  //#endregion
364
+ //#region src/popup.ts
365
+ /** The page's id for one popup sign-in, which its message carries back: what `attempt` takes. */
366
+ const POPUP_ATTEMPT = /^[A-Za-z0-9_-]{8,64}$/;
367
+ const POPUP_MESSAGE = "synq:popup";
368
+ /**
369
+ * The page's one script, constant so the Content-Security-Policy names it by
370
+ * hash (`POPUP_SCRIPT_HASH`): it reads the result from the JSON block beside
371
+ * it, posts it, and closes the window.
372
+ */
373
+ const POPUP_SCRIPT = "(function(){var d=document.getElementById('synq-result');var r=d?JSON.parse(d.textContent||'null'):null;if(r){try{var c=new BroadcastChannel('synq');c.postMessage(r);c.close()}catch(e){}}window.close()})()";
374
+ /** `POPUP_SCRIPT`'s SHA-256, as CSP's `script-src` takes it; its test holds the two together. */
375
+ const POPUP_SCRIPT_HASH = "sha256-OpmVimyyrlgsewsXQaWdNVXCa4ywJlk1XZL1thsfWk0=";
376
+ /** The popup's last page: it reports `result` and closes, or says the person may close it. */
377
+ function popupPage(result) {
378
+ const message = {
379
+ type: POPUP_MESSAGE,
380
+ ...result
381
+ };
382
+ const data = JSON.stringify(message).replaceAll("<", "\\u003c");
383
+ const words = result.ok ? "You’re signed in. You can close this window." : "That didn’t finish. You can close this window and try again.";
384
+ const html = `<!doctype html>
385
+ <html lang="en">
386
+ <head>
387
+ <meta charset="utf-8">
388
+ <meta name="viewport" content="width=device-width, initial-scale=1">
389
+ <meta name="robots" content="noindex">
390
+ <title>${result.ok ? "Signed in" : "Not signed in"}</title>
391
+ </head>
392
+ <body>
393
+ <p>${words}</p>
394
+ <script type="application/json" id="synq-result">${data}<\/script>
395
+ <script>${POPUP_SCRIPT}<\/script>
396
+ </body>
397
+ </html>
398
+ `;
399
+ return new Response(html, {
400
+ status: 200,
401
+ headers: {
402
+ "content-type": "text/html; charset=utf-8",
403
+ "cache-control": "no-store",
404
+ "content-security-policy": `default-src 'none'; script-src '${POPUP_SCRIPT_HASH}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
405
+ "referrer-policy": "no-referrer"
406
+ }
407
+ });
408
+ }
409
+ //#endregion
270
410
  //#region src/session-name.ts
271
411
  const SESSION_COOKIE = "synq.session";
272
412
  //#endregion
@@ -585,19 +725,174 @@ function now$1() {
585
725
  return Math.floor(Date.now() / 1e3);
586
726
  }
587
727
  //#endregion
728
+ //#region src/sign-in-context.ts
729
+ /** The cookie that remembers the way in: the browser keeps it as long as Chrome allows, 400 days. */
730
+ const LAST_USED = "synq.last-used";
731
+ const LAST_USED_MAX_AGE = 3456e4;
732
+ /** The cookie that holds a sign-in until the page hears of it: five minutes at most. */
733
+ const EVENT = "synq.event";
734
+ const EVENT_MAX_AGE = 300;
735
+ /** A method's id, as the issuer names them. */
736
+ const METHOD$1 = /^[a-z0-9][a-z0-9_-]{0,63}$/;
737
+ /** How long a list of methods is kept: what the issuer allows. */
738
+ const METHODS_TTL_MS = 6e4;
739
+ /**
740
+ * How long the callback waits for the issuer's list to learn whether the
741
+ * brand shows "Last used": past it, the sign-in goes on and "Last used"
742
+ * waits for the next one.
743
+ */
744
+ const METHODS_WAIT_MS = 2e3;
745
+ /**
746
+ * A wallet app's name as the issuer keeps it: letters, digits, spaces and a
747
+ * little punctuation, at most 32 characters. Anything else isn't kept.
748
+ */
749
+ function walletAppName(value) {
750
+ if (typeof value !== "string") return void 0;
751
+ const name = value.normalize("NFC").trim().replace(/\s+/g, " ");
752
+ return name.length > 0 && name.length <= 32 && /^[\p{L}\p{N} .'_-]+$/u.test(name) ? name : void 0;
753
+ }
754
+ /** What the session's ID token says of its sign-in, when the app turned the claims on. */
755
+ function signInOf(claims) {
756
+ const method = claims?.sign_in_method;
757
+ const at = claims?.auth_time;
758
+ if (typeof method !== "string" || !METHOD$1.test(method) || typeof at !== "number") return void 0;
759
+ const wallet = claims?.sign_in_wallet;
760
+ return {
761
+ id: crypto.randomUUID(),
762
+ isNewUser: claims?.new_user === true,
763
+ method,
764
+ wallet: wallet && typeof wallet.address === "string" ? {
765
+ address: wallet.address,
766
+ app: walletAppName(wallet.app) ?? null
767
+ } : null,
768
+ at
769
+ };
770
+ }
771
+ /** Remembers on this browser the way it signed in: the method, and a wallet app's name. */
772
+ function rememberLastUsed(jar, event, secure) {
773
+ const app = event.wallet?.app ?? void 0;
774
+ const value = app ? `${event.method}.${encodeURIComponent(app)}` : event.method;
775
+ jar.set(cookieName(LAST_USED, secure), value, {
776
+ secure,
777
+ maxAge: LAST_USED_MAX_AGE
778
+ });
779
+ }
780
+ /** The way this browser last signed in to the app, as its cookie holds it, or null. */
781
+ function lastUsedOf(jar, secure) {
782
+ const value = jar.get(cookieName(LAST_USED, secure));
783
+ if (!value || value.length > 160) return null;
784
+ const dot = value.indexOf(".");
785
+ const method = dot < 0 ? value : value.slice(0, dot);
786
+ if (!METHOD$1.test(method)) return null;
787
+ if (dot < 0) return { method };
788
+ let app;
789
+ try {
790
+ app = walletAppName(decodeURIComponent(value.slice(dot + 1)));
791
+ } catch {
792
+ app = void 0;
793
+ }
794
+ return app ? {
795
+ method,
796
+ walletApp: app
797
+ } : { method };
798
+ }
799
+ /** Forgets the way in: where the brand turned "Last used" off. */
800
+ function forgetLastUsed(jar, secure) {
801
+ if (jar.get(cookieName(LAST_USED, secure)) !== void 0) jar.delete(cookieName(LAST_USED, secure), { secure });
802
+ }
803
+ /** Keeps `event` for the page, sealed, bound to the person it is for (`sub`). */
804
+ async function keepEvent(jar, key, sub, event, secure) {
805
+ const name = cookieName(EVENT, secure);
806
+ const until = Math.floor(Date.now() / 1e3) + EVENT_MAX_AGE;
807
+ jar.set(name, await seal(key, JSON.stringify({
808
+ sub,
809
+ event,
810
+ until
811
+ }), name), {
812
+ secure,
813
+ maxAge: EVENT_MAX_AGE
814
+ });
815
+ }
816
+ /** The sign-in kept for the page, when it is `sub`'s and still fresh, or null. */
817
+ async function eventOf(jar, keys, sub, secure, now) {
818
+ if (!sub) return null;
819
+ const name = cookieName(EVENT, secure);
820
+ const opened = await unseal(keys, jar.get(name), name);
821
+ if (!opened) return null;
822
+ try {
823
+ const kept = JSON.parse(opened);
824
+ if (kept.sub !== sub || typeof kept.event?.id !== "string" || typeof kept.until !== "number" || kept.until < now) return null;
825
+ return kept.event;
826
+ } catch {
827
+ return null;
828
+ }
829
+ }
830
+ /** Forgets the kept sign-in: the page heard of it, or the person signed out. */
831
+ function forgetEvent(jar, secure) {
832
+ if (jar.get(cookieName(EVENT, secure)) !== void 0) jar.delete(cookieName(EVENT, secure), { secure });
833
+ }
834
+ /**
835
+ * `promise`'s value, or undefined when `ms` milliseconds pass first: the
836
+ * caller goes on, and the promise settles on its own.
837
+ */
838
+ async function within(promise, ms) {
839
+ let timer;
840
+ try {
841
+ return await Promise.race([promise, new Promise((resolve) => {
842
+ timer = setTimeout(resolve, ms);
843
+ })]);
844
+ } finally {
845
+ clearTimeout(timer);
846
+ }
847
+ }
848
+ /**
849
+ * The app's sign-in methods from its issuer (`GET /sign-in/methods`), kept a
850
+ * minute in this process, as the issuer allows; one read at a time.
851
+ */
852
+ function methodsReader(options) {
853
+ let kept;
854
+ let reading;
855
+ return () => {
856
+ if (kept && kept.until > Date.now()) return Promise.resolve(kept.list);
857
+ reading ??= signInMethods(options).then((list) => {
858
+ kept = {
859
+ list: readMethodList(list),
860
+ until: Date.now() + METHODS_TTL_MS
861
+ };
862
+ return kept.list;
863
+ }).finally(() => {
864
+ reading = void 0;
865
+ });
866
+ return reading;
867
+ };
868
+ }
869
+ //#endregion
588
870
  //#region src/core.ts
589
871
  const PROMPTS = /* @__PURE__ */ new Set([
590
872
  "login",
591
873
  "none",
592
874
  "consent",
593
- "select_account"
875
+ "select_account",
876
+ "create"
594
877
  ]);
595
- const INTENTS = /* @__PURE__ */ new Set(["add-passkey"]);
878
+ /** A plan's code, as Synq's catalog has it: what `plan` may hold (AERO-2160). */
879
+ const PLAN_CODE = /^[a-z][a-z0-9-]{1,30}$/;
880
+ /** An onboarding visit's id: 22 base62 characters (AERO-2160). */
881
+ const ONBOARDING_VISIT = /^[0-9A-Za-z]{22}$/;
596
882
  /** A sign-in method's name, as `synq_method` and the account API's link requests take it. */
597
883
  const METHOD = /^[a-z0-9][a-z0-9_-]{0,63}$/;
598
884
  const TRANSACTION_SECONDS = 3600;
599
885
  const TRANSACTION_PREFIX = "synq.tx.";
600
886
  const ACCOUNT_BODY_LIMIT = 65536;
887
+ /** A picture the person uploads: the account API's own limit (AERO-2700). */
888
+ const PICTURE_BODY_LIMIT = 5242880;
889
+ /** The pictures Synq takes, by the request's `Content-Type`; Synq knows them again by their bytes. */
890
+ const PICTURE_TYPES = /* @__PURE__ */ new Set([
891
+ "image/png",
892
+ "image/jpeg",
893
+ "image/webp",
894
+ "image/gif"
895
+ ]);
601
896
  const LINK_BODY_LIMIT = 4096;
602
897
  /** A step of an own-UI sign-in: a passkey's assertion is the most it carries. */
603
898
  const FLOW_BODY_LIMIT = 16384;
@@ -627,7 +922,9 @@ const PROTOCOL_CLAIMS = /* @__PURE__ */ new Set([
627
922
  function createSynqCore(config) {
628
923
  /** Whether the missing end-user address was logged already: once is enough. */
629
924
  let warnedAboutAddress = false;
630
- const keysFor = () => sealingKeys(config.sessionSecret, config.previousSessionSecrets);
925
+ /** The keys this app's (or site's, `config.site`) cookies are sealed with, derived once. */
926
+ let keys;
927
+ const keysFor = () => keys ??= sealingKeys(config.sessionSecret, config.previousSessionSecrets, config.site);
631
928
  /** Every request to Synq: never cached, and given up after REQUEST_TIMEOUT_MS. */
632
929
  const upstream = (input, init = {}) => {
633
930
  const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
@@ -669,6 +966,43 @@ function createSynqCore(config) {
669
966
  sid: typeof claims.sid === "string" ? claims.sid : void 0
670
967
  };
671
968
  }
969
+ /**
970
+ * Why a sign-in again (`reauthenticate`), for `sub`, can't be kept, or
971
+ * `undefined` when it can: `not_signed_in` when `sub` isn't the one signed
972
+ * in here any more (signed out in another tab, say, or someone else signed
973
+ * in since), `other_account` when someone else signed in at the brand.
974
+ */
975
+ async function reauthenticationRefused(jar, handle, sub) {
976
+ const previous = await load(jar);
977
+ const [before, after] = await Promise.all([previous ? issuerSessionOf(previous) : void 0, issuerSessionOf(handle)]);
978
+ if (before?.sub !== sub) return "not_signed_in";
979
+ if (after?.sub !== sub) return "other_account";
980
+ }
981
+ /**
982
+ * Ends a sign-in the callback won't keep: the browser's session at the
983
+ * brand that it made, from this server, then its grant. Signed in with
984
+ * `prompt=login`, it replaced the brand's session in this browser, and
985
+ * left alive it would sign whoever it was in to any later sign-in that
986
+ * asks for no fresh one, at this app or another of the brand's.
987
+ */
988
+ async function discard(handle) {
989
+ const theirs = client(handle.storage(config.primary, config.primary), config.primary);
990
+ try {
991
+ await theirs.signOut({ stay: true });
992
+ } catch {
993
+ try {
994
+ await theirs.signOut();
995
+ } catch {}
996
+ }
997
+ }
998
+ /** The ID token the session's main token came with: who is signed in, as the issuer said. */
999
+ async function idTokenOf(handle) {
1000
+ if (!handle) return void 0;
1001
+ const raw = (await handle.read())[tokenKey(config.primary)];
1002
+ if (!raw) return void 0;
1003
+ const { idToken } = JSON.parse(raw);
1004
+ return typeof idToken === "string" ? idToken : void 0;
1005
+ }
672
1006
  /** The person the session's main token was issued with, while the session can still give a token. */
673
1007
  async function userOf(handle) {
674
1008
  if (!handle) return {
@@ -698,6 +1032,48 @@ function createSynqCore(config) {
698
1032
  expiresAt
699
1033
  };
700
1034
  }
1035
+ /** Who is signed in, the way in this browser last used, and the sign-in the page hasn't heard of. */
1036
+ async function sessionInfo(jar) {
1037
+ const { user, expiresAt } = await userOf(await load(jar));
1038
+ return {
1039
+ user,
1040
+ expiresAt,
1041
+ lastUsed: lastUsedOf(jar, config.secure),
1042
+ event: await eventOf(jar, (await keysFor()).open, user?.sub, config.secure, now())
1043
+ };
1044
+ }
1045
+ /** The app's sign-in methods (AERO-2690), from the issuer, kept a minute. */
1046
+ const methods = methodsReader({
1047
+ issuer: config.issuer,
1048
+ clientId: config.clientId,
1049
+ fetch: upstream
1050
+ });
1051
+ /**
1052
+ * What the new session's ID token says of its sign-in, kept on this
1053
+ * browser (AERO-2691): the way in, for "Last used" where the brand shows
1054
+ * it, and the sign-in itself, for the page to hear of once (`event`).
1055
+ * Nothing without the app's sign-in claims. Best effort: the sign-in
1056
+ * stands whatever happens here, and waits at most METHODS_WAIT_MS for the
1057
+ * issuer's list.
1058
+ *
1059
+ * A link, or the person signed in now confirming it's them, is no sign-in
1060
+ * for the page to hear of (`event: false`): the person signed in is the
1061
+ * same, and a panel open on its onboarding or its profile carries on there
1062
+ * rather than greeting them again.
1063
+ */
1064
+ async function keepSignIn(jar, handle, { event: keep = true } = {}) {
1065
+ const raw = (await handle.read())[tokenKey(config.primary)];
1066
+ const claims = raw ? JSON.parse(raw).claims : void 0;
1067
+ const event = signInOf(claims);
1068
+ if (!event || typeof claims?.sub !== "string") return;
1069
+ if (keep) await keepEvent(jar, (await keysFor()).seal, claims.sub, event, config.secure);
1070
+ try {
1071
+ const list = await within(methods(), METHODS_WAIT_MS);
1072
+ if (!list) return;
1073
+ if (list.show_last_used) rememberLastUsed(jar, event, config.secure);
1074
+ else forgetLastUsed(jar, config.secure);
1075
+ } catch {}
1076
+ }
701
1077
  async function getAccessToken(jar, options = {}) {
702
1078
  const resource = options.resource ?? config.primary;
703
1079
  if (resource !== config.primary && resource !== config.accountApi) throw new SynqError("invalid_options", `The session has no token for ${resource ?? "userinfo"}: name it in synqAuth()'s resource`);
@@ -718,8 +1094,14 @@ function createSynqCore(config) {
718
1094
  throw error;
719
1095
  }
720
1096
  }
721
- /** A store for one sign-in's transaction: a sealed cookie named after its state, with where to go afterwards. */
722
- async function transactions(jar, returnTo) {
1097
+ /**
1098
+ * A store for one sign-in's transaction: a sealed cookie named after its
1099
+ * state, with where to go afterwards, or the popup it runs in (`popup`, the
1100
+ * page's attempt id: popup.ts), for a sign-in again, who must sign in
1101
+ * (`reauthenticate`, their `sub`), and whether it adds a way in to the
1102
+ * person signed in (`link`) rather than signing someone in.
1103
+ */
1104
+ async function transactions(jar, returnTo, popup, reauthenticate, link) {
723
1105
  const keys = await keysFor();
724
1106
  const options = {
725
1107
  secure: config.secure,
@@ -732,8 +1114,11 @@ function createSynqCore(config) {
732
1114
  const cookie = cookieName(name, config.secure);
733
1115
  const opened = await unseal(keys.open, jar.get(cookie), cookie);
734
1116
  if (!opened) return void 0;
735
- const { v, r } = JSON.parse(opened);
1117
+ const { v, r, p, s, l } = JSON.parse(opened);
736
1118
  found.returnTo = r;
1119
+ if (typeof p === "string" && POPUP_ATTEMPT.test(p)) found.popup = p;
1120
+ if (typeof s === "string") found.reauthenticate = s;
1121
+ if (l === true) found.link = true;
737
1122
  return v;
738
1123
  },
739
1124
  async set(name, value) {
@@ -742,7 +1127,10 @@ function createSynqCore(config) {
742
1127
  const sealed = JSON.stringify({
743
1128
  v: value,
744
1129
  r: returnTo,
745
- t: Date.now()
1130
+ t: Date.now(),
1131
+ p: popup,
1132
+ s: reauthenticate,
1133
+ ...link ? { l: true } : {}
746
1134
  });
747
1135
  jar.set(cookie, await seal(keys.seal, sealed, cookie), options);
748
1136
  },
@@ -782,41 +1170,116 @@ function createSynqCore(config) {
782
1170
  const prompt = params.get("prompt") ?? void 0;
783
1171
  const maxAge = params.get("max_age");
784
1172
  const intent = params.get("intent") ?? void 0;
785
- if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent or select_account");
1173
+ if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent, select_account or create");
786
1174
  if (maxAge !== null && !/^\d{1,9}$/.test(maxAge)) return problem(400, "invalid_request", "max_age is a number of seconds");
787
- if (intent && !INTENTS.has(intent)) return problem(400, "invalid_request", "intent is add-passkey (POST link adds a way to sign in)");
788
- const tx = await transactions(jar, returnTo);
1175
+ if (intent && !SIGN_IN_INTENTS.has(intent)) return problem(400, "invalid_request", "intent is add-passkey or verify-email (POST link adds a way to sign in)");
1176
+ const plan = params.get("plan") ?? void 0;
1177
+ const interval = params.get("interval") ?? void 0;
1178
+ if (plan !== void 0 && !PLAN_CODE.test(plan)) return problem(400, "invalid_request", "plan is a plan code, such as pro");
1179
+ if (interval !== void 0 && (plan === void 0 || interval !== "month" && interval !== "year")) return problem(400, "invalid_request", "interval is month or year, with a plan");
1180
+ const onboarding = params.get("onboarding") ?? void 0;
1181
+ if (onboarding !== void 0 && !ONBOARDING_VISIT.test(onboarding)) return problem(400, "invalid_request", "onboarding is an onboarding visit id");
1182
+ const display = displayOf(params.get("display"), params.get("attempt"));
1183
+ if (display instanceof Response) return display;
1184
+ const again = params.get("reauthenticate");
1185
+ let reauthenticate;
1186
+ if (again !== null) {
1187
+ if (again !== "true") return problem(400, "invalid_request", "reauthenticate is true, or left out");
1188
+ if (prompt && prompt !== "login" || intent) return problem(400, "invalid_request", "reauthenticate is a fresh sign-in: it takes no other prompt, and no intent");
1189
+ const signedIn = await load(jar);
1190
+ reauthenticate = signedIn ? (await issuerSessionOf(signedIn))?.sub : void 0;
1191
+ if (!reauthenticate) {
1192
+ if (display) return popupPage({
1193
+ attempt: display,
1194
+ ok: false,
1195
+ error: "not_signed_in"
1196
+ });
1197
+ return redirect(withError(returnTo, "not_signed_in"), 302);
1198
+ }
1199
+ }
1200
+ const tx = await transactions(jar, returnTo, display, reauthenticate);
789
1201
  return redirect((await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
790
1202
  method: params.get("method") ?? void 0,
791
1203
  loginHint: params.get("login_hint") ?? void 0,
792
- prompt: prompt ?? (intent ? "login" : void 0),
1204
+ prompt: reauthenticate ? "login" : intentPrompt(intent, prompt),
793
1205
  maxAge: maxAge === null ? void 0 : Number(maxAge),
794
1206
  additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
795
- params: intent ? { synq_intent: intent } : void 0
1207
+ params: {
1208
+ ...intent ? { synq_intent: intent } : {},
1209
+ ...plan ? { synq_plan: plan } : {},
1210
+ ...interval ? { synq_interval: interval } : {},
1211
+ ...onboarding ? { synq_onboarding: onboarding } : {}
1212
+ }
796
1213
  })).href, 302);
797
1214
  }
798
1215
  async function callback(request, jar) {
799
1216
  const tx = await transactions(jar);
800
1217
  const handle = newSession(config.sessionMaxAge, config.store);
1218
+ const back = () => safeReturnTo(tx.found.returnTo ?? null, config.appUrl);
1219
+ const failed = (code) => tx.found.popup ? popupPage({
1220
+ attempt: tx.found.popup,
1221
+ ok: false,
1222
+ error: code
1223
+ }) : redirect(withError(back(), code), 303);
801
1224
  try {
802
1225
  await client(handle.storage(config.primary, config.primary), config.primary, tx.storage).handleCallback(request.url);
803
1226
  } catch (error) {
804
- const code = isSynqError(error) ? error.code : "server_error";
805
- return redirect(withError(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), code), 303);
1227
+ return failed(isSynqError(error) ? error.code : "server_error");
1228
+ }
1229
+ if (tx.found.reauthenticate !== void 0) {
1230
+ const refused = await reauthenticationRefused(jar, handle, tx.found.reauthenticate);
1231
+ if (refused) {
1232
+ await discard(handle);
1233
+ return failed(refused);
1234
+ }
806
1235
  }
807
1236
  await startSession(jar, handle);
808
- return redirect(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), 303);
1237
+ await keepSignIn(jar, handle, { event: tx.found.reauthenticate === void 0 && tx.found.link !== true });
1238
+ if (tx.found.popup) return popupPage({
1239
+ attempt: tx.found.popup,
1240
+ ok: true
1241
+ });
1242
+ return redirect(back(), 303);
809
1243
  }
810
1244
  async function signOut(request, jar) {
1245
+ const stay = await staysOnPage(request);
1246
+ if (stay instanceof Response) return stay;
811
1247
  const handle = await load(jar);
812
1248
  let url = null;
813
- if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut();
1249
+ if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut(stay ? { stay: true } : {});
814
1250
  await endSession(jar, handle, config.secure, config.store);
1251
+ forgetEvent(jar, config.secure);
1252
+ if (stay) return json({ redirect: null });
815
1253
  const target = url?.href ?? config.postLogoutRedirectUri;
816
1254
  if ((request.headers.get("accept") ?? "").includes("application/json")) return json({ redirect: target });
817
1255
  return redirect(target, 303);
818
1256
  }
819
1257
  /**
1258
+ * Whether a sign-out asks to stay on the page (AERO-2684): `{ stay: true }`
1259
+ * as JSON. A form's post, or no body, goes to the brand's sign-out page.
1260
+ */
1261
+ async function staysOnPage(request) {
1262
+ if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return false;
1263
+ const length = Number(request.headers.get("content-length") ?? 0);
1264
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
1265
+ if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1266
+ try {
1267
+ const body = await request.text();
1268
+ if (body === "") return false;
1269
+ const input = JSON.parse(body);
1270
+ if (input === null || typeof input !== "object" || Array.isArray(input)) throw new TypeError();
1271
+ if (input.stay !== void 0 && typeof input.stay !== "boolean") throw new TypeError();
1272
+ return input.stay === true;
1273
+ } catch {
1274
+ return problem(400, "invalid_request", "Send { stay: true } as JSON, or no body");
1275
+ }
1276
+ }
1277
+ /** The ways the app's sign-in offers, for its own panel (AERO-2690): the issuer's list, kept a minute. */
1278
+ async function signInMethodsRoute() {
1279
+ const list = await methods();
1280
+ return Response.json(list, { headers: { "cache-control": "private, max-age=60" } });
1281
+ }
1282
+ /**
820
1283
  * Adding a way to sign in (AERO-1397): the account API makes a link request
821
1284
  * for the person with the session's token, and its ticket goes to Synq in
822
1285
  * a sign-in like any other, which asks them to sign in again first. A POST
@@ -837,6 +1300,8 @@ function createSynqCore(config) {
837
1300
  return problem(400, "invalid_request", "Send { method, returnTo } as JSON");
838
1301
  }
839
1302
  if (typeof input?.method !== "string" || !METHOD.test(input.method)) return problem(400, "invalid_request", "method names the way to sign in to add");
1303
+ const display = displayOf(textOrNull(input.display), textOrNull(input.attempt));
1304
+ if (display instanceof Response) return display;
840
1305
  const returnTo = safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl);
841
1306
  let token;
842
1307
  try {
@@ -849,6 +1314,23 @@ function createSynqCore(config) {
849
1314
  ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
850
1315
  throw error;
851
1316
  }
1317
+ if (input.method === "passkey") {
1318
+ const methods = await upstream(`${config.accountApi}/v1/link-methods?limit=200`, { headers: {
1319
+ authorization: `Bearer ${token}`,
1320
+ accept: "application/json"
1321
+ } });
1322
+ if (!methods.ok) return new Response(methods.body, {
1323
+ status: methods.status,
1324
+ headers: { "content-type": methods.headers.get("content-type") ?? "application/problem+json" }
1325
+ });
1326
+ if (!(await methods.json()).data?.some((one) => one.method === "passkey")) return problem(409, "sign_in_method_unavailable", "This brand doesn’t offer passkeys");
1327
+ const tx = await transactions(jar, returnTo, display, void 0, true);
1328
+ return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
1329
+ prompt: "login",
1330
+ additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
1331
+ params: { synq_intent: "add-passkey" }
1332
+ })).href });
1333
+ }
852
1334
  const answer = await upstream(`${config.accountApi}/v1/link-requests`, {
853
1335
  method: "POST",
854
1336
  headers: {
@@ -864,7 +1346,7 @@ function createSynqCore(config) {
864
1346
  });
865
1347
  const ticket = (await answer.json()).authorization_params?.synq_link;
866
1348
  if (typeof ticket !== "string") return problem(502, "invalid_response", "Synq answered without a link ticket");
867
- const tx = await transactions(jar, returnTo);
1349
+ const tx = await transactions(jar, returnTo, display, void 0, true);
868
1350
  return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
869
1351
  prompt: "login",
870
1352
  additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
@@ -941,9 +1423,16 @@ function createSynqCore(config) {
941
1423
  warnedAboutAddress = true;
942
1424
  console.warn("@synqauth/nextjs: own-UI sign-in can't tell where people sign in from (no x-real-ip): give synqAuth() a clientAddress, or per-address protection is off for this app");
943
1425
  }
1426
+ const reauthenticate = input.reauthenticate === true;
1427
+ if (reauthenticate && step !== "wallet") return problem(400, "invalid_request", "reauthenticate goes with step wallet");
1428
+ const signedIn = reauthenticate ? await idTokenOf(await load(jar)) : void 0;
1429
+ if (reauthenticate && !signedIn) return json({
1430
+ status: "other-account",
1431
+ message: "Nobody is signed in to confirm."
1432
+ });
944
1433
  const handle = newSession(config.sessionMaxAge, config.store);
945
1434
  const synq = client(handle.storage(config.primary, config.primary), config.primary);
946
- const flow = saved ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
1435
+ const flow = saved && !reauthenticate ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
947
1436
  endUserIp,
948
1437
  additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : []
949
1438
  });
@@ -981,7 +1470,13 @@ function createSynqCore(config) {
981
1470
  result = await flow.passkey.signIn(credential, { related: input.related === true });
982
1471
  break;
983
1472
  }
984
- default: return problem(400, "invalid_request", "step is password, email, code, resend, skip, passkey-options, passkey or restart");
1473
+ case "wallet": {
1474
+ const siws = input.siws;
1475
+ if (typeof siws !== "string" || !SIWS.test(siws)) return problem(400, "invalid_request", "siws is what the wallet signed, as encodeSiws makes it");
1476
+ result = await flow.wallet.signIn(siws, signedIn ? { reauthenticate: signedIn } : {});
1477
+ break;
1478
+ }
1479
+ default: return problem(400, "invalid_request", "step is password, email, code, resend, skip, passkey-options, passkey, wallet or restart");
985
1480
  }
986
1481
  } catch (error) {
987
1482
  if (isSynqError(error, "invalid_session")) {
@@ -1002,7 +1497,21 @@ function createSynqCore(config) {
1002
1497
  }
1003
1498
  if (result.status === "signed-in") {
1004
1499
  await tx.storage.remove(FLOW_KEY);
1500
+ if (reauthenticate) {
1501
+ const previous = await load(jar);
1502
+ const [before, after] = await Promise.all([previous ? issuerSessionOf(previous) : void 0, issuerSessionOf(handle)]);
1503
+ if (!before || before.sub !== after?.sub) {
1504
+ try {
1505
+ await client(handle.storage(config.primary, config.primary), config.primary).signOut();
1506
+ } catch {}
1507
+ return json({
1508
+ status: "other-account",
1509
+ message: before ? "That wallet signs in to another account. Use a wallet of the account signed in here." : "Nobody is signed in to confirm."
1510
+ });
1511
+ }
1512
+ }
1005
1513
  await startSession(jar, handle);
1514
+ await keepSignIn(jar, handle, { event: !reauthenticate });
1006
1515
  return json({
1007
1516
  status: "signed-in",
1008
1517
  returnTo: tx.found.returnTo ?? returnTo
@@ -1050,11 +1559,16 @@ function createSynqCore(config) {
1050
1559
  ...result.loginHint ? { loginHint: result.loginHint } : {}
1051
1560
  }
1052
1561
  });
1053
- case "refused": return json({
1054
- status: result.status,
1055
- reason: result.reason,
1056
- message: result.message
1057
- });
1562
+ case "refused":
1563
+ if (reauthenticate && result.reason === "other_account") return json({
1564
+ status: "other-account",
1565
+ message: "That wallet signs in to another account, or to none. Use a wallet of the account signed in here."
1566
+ });
1567
+ return json({
1568
+ status: result.status,
1569
+ reason: result.reason,
1570
+ message: result.message
1571
+ });
1058
1572
  }
1059
1573
  }
1060
1574
  /**
@@ -1126,6 +1640,85 @@ function createSynqCore(config) {
1126
1640
  headers: out
1127
1641
  });
1128
1642
  }
1643
+ /**
1644
+ * The person's picture (AERO-2700): the browser posts the picture's own
1645
+ * bytes here, and this server runs the account API's upload for them,
1646
+ * asking for an upload, PUTting the bytes to Synq's storage, and choosing
1647
+ * it, so the storage needs no CORS for the app, and no bytes pass through
1648
+ * Synq's API. Or it posts `{ "identity": … }`, a linked account whose
1649
+ * picture to use: a POST, which every app's route file already serves. A
1650
+ * body that says it is too large, or not a picture, or won't say how long
1651
+ * it is, is refused before it is read.
1652
+ */
1653
+ async function picture(request, jar) {
1654
+ if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
1655
+ const declared = request.headers.get("content-length");
1656
+ if (request.headers.has("transfer-encoding") || declared === null) return problem(411, "length_required", "Send the picture with a Content-Length");
1657
+ const length = Number(declared);
1658
+ if (!Number.isSafeInteger(length) || length < 1) return problem(411, "length_required", "Send the picture with a Content-Length");
1659
+ if (length > PICTURE_BODY_LIMIT) return problem(413, "payload_too_large", "A picture is at most 5 MB.");
1660
+ const type = request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
1661
+ const linked = type === "application/json";
1662
+ if (linked && length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1663
+ if (!linked && !PICTURE_TYPES.has(type)) return problem(415, "unsupported_media_type", "Send a PNG, JPEG, WebP or GIF picture");
1664
+ let token;
1665
+ try {
1666
+ token = await getAccessToken(jar, { resource: config.accountApi });
1667
+ } catch (error) {
1668
+ if (isSynqError(error) && [
1669
+ "not_signed_in",
1670
+ "invalid_grant",
1671
+ "no_refresh_token"
1672
+ ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
1673
+ throw error;
1674
+ }
1675
+ const bytes = new Uint8Array(await request.arrayBuffer());
1676
+ if (bytes.byteLength !== length) return problem(400, "validation_failed", "The picture isn’t the length it said");
1677
+ const key = request.headers.get("idempotency-key");
1678
+ const call = (method, path, body, step) => upstream(`${config.accountApi}/${path}`, {
1679
+ method,
1680
+ headers: {
1681
+ authorization: `Bearer ${token}`,
1682
+ accept: "application/json",
1683
+ "content-type": "application/json",
1684
+ ...key ? { "idempotency-key": `${key}:${step}` } : {}
1685
+ },
1686
+ body: JSON.stringify(body),
1687
+ redirect: "manual"
1688
+ });
1689
+ const relay = async (answer) => new Response(answer.status === 204 ? null : await answer.text(), {
1690
+ status: answer.status,
1691
+ headers: {
1692
+ "cache-control": "no-store",
1693
+ "content-type": answer.headers.get("content-type") ?? "application/json"
1694
+ }
1695
+ });
1696
+ if (linked) {
1697
+ let identity;
1698
+ try {
1699
+ identity = JSON.parse(new TextDecoder().decode(bytes)).identity;
1700
+ } catch {
1701
+ identity = void 0;
1702
+ }
1703
+ if (typeof identity !== "string" || !/^idn_[0-9a-z]{1,64}$/.test(identity)) return problem(400, "validation_failed", "Name the linked account: { \"identity\": \"idn_…\" }");
1704
+ return relay(await call("PUT", "v1/profile/picture", { identity }, "choose"));
1705
+ }
1706
+ const started = await call("POST", "v1/profile/picture/uploads", {
1707
+ content_type: type,
1708
+ content_length: length
1709
+ }, "upload");
1710
+ if (started.status !== 201) return relay(started);
1711
+ const upload = await started.json();
1712
+ const stored = await upstream(upload.url, {
1713
+ method: "PUT",
1714
+ headers: upload.headers,
1715
+ body: bytes,
1716
+ redirect: "manual"
1717
+ });
1718
+ await stored.body?.cancel();
1719
+ if (!stored.ok) return problem(502, "upload_failed", `Synq's storage refused the picture (${stored.status})`);
1720
+ return relay(await call("PUT", "v1/profile/picture", { file_id: upload.file_id }, "choose"));
1721
+ }
1129
1722
  async function plugin(request, jar, id, rest) {
1130
1723
  const route = config.plugins.find((candidate) => candidate.id === id)?.routes?.[`${request.method} /${rest}`];
1131
1724
  if (!route) return void 0;
@@ -1150,10 +1743,19 @@ function createSynqCore(config) {
1150
1743
  if (rest === "sign-in" && request.method === "GET") response = await signIn(request, jar);
1151
1744
  else if (rest === "sign-in" && request.method === "POST") response = await walletSignIn(request, jar);
1152
1745
  else if (rest === "sign-in/flow" && request.method === "POST") response = await signInFlow(request, jar);
1746
+ else if (rest === "sign-in/wallet" && request.method === "GET") response = json({
1747
+ issuer: config.issuer,
1748
+ clientId: config.clientId
1749
+ });
1153
1750
  else if (rest === "callback" && request.method === "GET") response = await callback(request, jar);
1154
1751
  else if (rest === "sign-out" && request.method === "POST") response = await signOut(request, jar);
1155
1752
  else if (rest === "link" && request.method === "POST") response = await link(request, jar);
1156
- else if (rest === "session" && request.method === "GET") response = json(await userOf(await load(jar)));
1753
+ else if (rest === "sign-in/methods" && request.method === "GET") response = await signInMethodsRoute();
1754
+ else if (rest === "session" && request.method === "GET") response = json(await sessionInfo(jar));
1755
+ else if (rest === "session/event" && request.method === "DELETE") {
1756
+ forgetEvent(jar, config.secure);
1757
+ response = new Response(null, { status: 204 });
1758
+ } else if (rest === "account/picture" && request.method === "POST") response = await picture(request, jar);
1157
1759
  else if (first === "account") response = await account(request, jar, tail.join("/"));
1158
1760
  else if (!RESERVED.has(first)) response = await plugin(request, jar, first, tail.join("/"));
1159
1761
  } catch (error) {
@@ -1186,12 +1788,28 @@ function createSynqCore(config) {
1186
1788
  return {
1187
1789
  handle,
1188
1790
  user: async (jar) => (await userOf(await load(jar))).user,
1189
- session: async (jar) => userOf(await load(jar)),
1791
+ session: sessionInfo,
1190
1792
  getAccessToken,
1191
1793
  refreshIfDue,
1192
1794
  config
1193
1795
  };
1194
1796
  }
1797
+ /** A body field as text: null when it is left out, and anything but text kept as a mark that it was there. */
1798
+ function textOrNull(value) {
1799
+ if (value === void 0) return null;
1800
+ return typeof value === "string" ? value : "?";
1801
+ }
1802
+ /**
1803
+ * The popup a sign-in or link runs in (AERO-2692): the page's attempt id for
1804
+ * `display=popup`, undefined for `redirect` or none, or the problem for
1805
+ * anything else.
1806
+ */
1807
+ function displayOf(display, attempt) {
1808
+ if (display === null || display === "redirect") return attempt === null ? void 0 : problem(400, "invalid_request", "attempt goes with display=popup");
1809
+ if (display !== "popup") return problem(400, "invalid_request", "display is popup or redirect");
1810
+ if (attempt === null || !POPUP_ATTEMPT.test(attempt)) return problem(400, "invalid_request", "attempt is the popup's id: 8 to 64 of A-Z a-z 0-9 _ -");
1811
+ return attempt;
1812
+ }
1195
1813
  /**
1196
1814
  * Whether a request is a prefetch, not a person going there: Next.js's
1197
1815
  * router (`Next-Router-Prefetch`) and browsers' speculative loads (`Purpose`,
@@ -1272,7 +1890,9 @@ const MESSAGES = {
1272
1890
  consent_required: "You need to allow this app to use your account to continue.",
1273
1891
  account_selection_required: "Choose an account to continue.",
1274
1892
  temporarily_unavailable: "Signing in isn’t available just now. Try again in a moment.",
1275
- unknown_state: "This sign-in expired, or it finished in another tab. Try again."
1893
+ unknown_state: "This sign-in expired, or it finished in another tab. Try again.",
1894
+ other_account: "That signed in to another account. Confirm with a way in of the account signed in here.",
1895
+ not_signed_in: "You were signed out. Sign in again."
1276
1896
  };
1277
1897
  /** What a sign-in error code means, in plain words. */
1278
1898
  function signInErrorMessage(code) {
@@ -1346,4 +1966,129 @@ function signInErrorPage(error) {
1346
1966
  });
1347
1967
  }
1348
1968
  //#endregion
1349
- export { withoutSignInError as a, memorySessionStore as c, normalizePath as d, resolveConfig as f, signInErrorPage as i, defineSynqPlugin as l, signInErrorMessage as n, createSynqCore as o, signInErrorOf as r, keyValueSessionStore as s, SIGN_IN_ERROR_PARAM as t, requestJar as u };
1969
+ //#region src/sites.ts
1970
+ /** Sites kept at once; past it, the one used longest ago is made again when next asked for. */
1971
+ const MAX_SITES = 500;
1972
+ /** One DNS label, as a host name has them. */
1973
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
1974
+ /** A host as a browser names it in `Host`: a DNS name or an IPv6 literal, and a port. */
1975
+ const HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*|\[[0-9a-f:.]+\])(?::\d{1,5})?$/;
1976
+ /** A host as it's compared: lowercase, with no dot at the end of its name. */
1977
+ function normalHost(value) {
1978
+ return value.trim().toLowerCase().replace(/\.(:\d+)?$/, "$1");
1979
+ }
1980
+ /**
1981
+ * The host the browser asked for: `X-Forwarded-Host`'s first, which a proxy
1982
+ * in front of the app sets (and Next's own server does), else `Host`.
1983
+ * Undefined when there is none, or it isn't a host.
1984
+ */
1985
+ function requestHost(headers) {
1986
+ const forwarded = headers.get("x-forwarded-host")?.split(",", 1)[0]?.trim();
1987
+ const host = normalHost(forwarded || headers.get("host") || "");
1988
+ return HOST.test(host) ? host : void 0;
1989
+ }
1990
+ /**
1991
+ * `hosts` as a test: an entry is a host (`club.example.com`,
1992
+ * `localhost:3000`), or `*.` and a domain, for any one label under it, its
1993
+ * port named or none. Throws `invalid_options` for an entry that is neither.
1994
+ */
1995
+ function allowList(hosts) {
1996
+ if (!Array.isArray(hosts) || hosts.length === 0) throw new SynqError("invalid_options", "With resolve, hosts lists every host the app serves (club.example.com, *.example.com): any other is refused");
1997
+ const exact = /* @__PURE__ */ new Set();
1998
+ const under = [];
1999
+ for (const entry of hosts) {
2000
+ const host = typeof entry === "string" ? normalHost(entry) : "";
2001
+ const wildcard = host.startsWith("*.");
2002
+ const name = wildcard ? host.slice(2) : host;
2003
+ if (!HOST.test(name) || name.startsWith("[") || wildcard && !name.includes(".")) throw new SynqError("invalid_options", `hosts: ${JSON.stringify(entry)} isn't a host, nor *. and a domain`);
2004
+ if (wildcard) under.push(`.${name}`);
2005
+ else exact.add(name);
2006
+ }
2007
+ return (host) => exact.has(host) || under.some((suffix) => host.endsWith(suffix) && LABEL.test(host.slice(0, -suffix.length)));
2008
+ }
2009
+ /** Whether `host` (its name, without the port) is this machine. */
2010
+ function isLoopback(host) {
2011
+ const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":", 1)[0];
2012
+ return name === "localhost" || name.endsWith(".localhost") || name === "127.0.0.1" || name === "[::1]";
2013
+ }
2014
+ /**
2015
+ * The site's origin: `https://<host>`, or, for a host on this machine, the
2016
+ * scheme the request came over (`X-Forwarded-Proto`, which Next sets, else
2017
+ * the request's own URL).
2018
+ */
2019
+ function originOf(host, headers, url) {
2020
+ let protocol = "https:";
2021
+ if (isLoopback(host)) {
2022
+ const forwarded = headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim().toLowerCase();
2023
+ if (forwarded === "http" || forwarded === "https") protocol = `${forwarded}:`;
2024
+ else if (url) protocol = new URL(url).protocol === "https:" ? "https:" : "http:";
2025
+ else protocol = "http:";
2026
+ }
2027
+ return new URL(`${protocol}//${host}`).origin;
2028
+ }
2029
+ function siteCores(options) {
2030
+ const { resolve } = options;
2031
+ if (!resolve) throw new TypeError("siteCores needs resolve");
2032
+ let allowed;
2033
+ const cores = /* @__PURE__ */ new Map();
2034
+ return { async coreFor(headers, url) {
2035
+ allowed ??= allowList(options.hosts);
2036
+ const host = requestHost(headers);
2037
+ if (!host || !allowed(host)) return null;
2038
+ const request = {
2039
+ host,
2040
+ origin: originOf(host, headers, url),
2041
+ headers
2042
+ };
2043
+ const site = await resolve(request);
2044
+ if (site === null || site === void 0) return null;
2045
+ const key = JSON.stringify([
2046
+ request.origin,
2047
+ site.issuer,
2048
+ site.clientId,
2049
+ site.appUrl ?? null,
2050
+ site.storePrefix ?? null,
2051
+ site.account ?? null,
2052
+ site.postLogoutRedirectUri ?? null
2053
+ ]);
2054
+ const kept = cores.get(key);
2055
+ cores.delete(key);
2056
+ if (kept && kept.secret === site.clientSecret) {
2057
+ cores.set(key, kept);
2058
+ return kept.core;
2059
+ }
2060
+ const core = createSynqCore(siteConfig(options, site, request));
2061
+ cores.set(key, {
2062
+ secret: site.clientSecret,
2063
+ core
2064
+ });
2065
+ if (cores.size > MAX_SITES) cores.delete(cores.keys().next().value);
2066
+ return core;
2067
+ } };
2068
+ }
2069
+ const UNKNOWN_HOST = "This app doesn't serve the host this request was sent to: add it to synqAuth()'s hosts if it should";
2070
+ /** Why a request to a host the app doesn't serve gets nothing, as the error server code throws. */
2071
+ function unknownHostError() {
2072
+ return new SynqError("unknown_host", UNKNOWN_HOST, { status: 421 });
2073
+ }
2074
+ /**
2075
+ * The answer to a request for a host the app doesn't serve: 421 Misdirected
2076
+ * Request, as a problem, naming no host and no configuration.
2077
+ */
2078
+ function unknownHostResponse() {
2079
+ return Response.json({
2080
+ type: "about:blank",
2081
+ status: 421,
2082
+ code: "unknown_host",
2083
+ detail: UNKNOWN_HOST
2084
+ }, {
2085
+ status: 421,
2086
+ headers: {
2087
+ "content-type": "application/problem+json",
2088
+ "cache-control": "no-store",
2089
+ "x-content-type-options": "nosniff"
2090
+ }
2091
+ });
2092
+ }
2093
+ //#endregion
2094
+ export { signInErrorMessage as a, withoutSignInError as c, memorySessionStore as d, defineSynqPlugin as f, resolveConfig as h, SIGN_IN_ERROR_PARAM as i, createSynqCore as l, normalizePath as m, unknownHostError as n, signInErrorOf as o, requestJar as p, unknownHostResponse as r, signInErrorPage as s, siteCores as t, keyValueSessionStore as u };