@synqauth/nextjs 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.
@@ -0,0 +1,2094 @@
1
+ import { SynqError, createSynqClient, isSynqError, memoryStorage, readMethodList, signInMethods } from "@synqauth/js";
2
+ //#region src/crypto.ts
3
+ const encoder = new TextEncoder();
4
+ const decoder = new TextDecoder();
5
+ const VERSION = "v1";
6
+ const keys = /* @__PURE__ */ new Map();
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 = "") {
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}`);
15
+ let key = keys.get(secret);
16
+ if (!key) {
17
+ key = deriveKey(secret, VERSION);
18
+ keys.set(secret, key);
19
+ }
20
+ return key;
21
+ }
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)));
36
+ return {
37
+ seal: open[0],
38
+ open
39
+ };
40
+ }
41
+ /** `plaintext` sealed for the cookie `name`: `v1.<iv>.<ciphertext>`, base64url. */
42
+ async function seal(key, plaintext, name) {
43
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
44
+ const sealed = await crypto.subtle.encrypt({
45
+ name: "AES-GCM",
46
+ iv,
47
+ additionalData: encoder.encode(name)
48
+ }, key, encoder.encode(plaintext));
49
+ return `${VERSION}.${base64url(iv)}.${base64url(new Uint8Array(sealed))}`;
50
+ }
51
+ /** What `seal` sealed for `name` with any of `keys`, or undefined for anything else: another cookie's, tampered, or another secret's. */
52
+ async function unseal(keys, sealed, name) {
53
+ if (!sealed) return void 0;
54
+ const [version, iv, data, ...rest] = sealed.split(".");
55
+ if (version !== VERSION || !iv || !data || rest.length > 0) return void 0;
56
+ const candidates = Array.isArray(keys) ? keys : [keys];
57
+ for (const key of candidates) try {
58
+ const opened = await crypto.subtle.decrypt({
59
+ name: "AES-GCM",
60
+ iv: fromBase64url(iv),
61
+ additionalData: encoder.encode(name)
62
+ }, key, fromBase64url(data));
63
+ return decoder.decode(opened);
64
+ } catch {}
65
+ }
66
+ /** A random id: 128 bits, base64url. */
67
+ function randomId() {
68
+ return base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)));
69
+ }
70
+ function base64url(bytes) {
71
+ let binary = "";
72
+ for (const byte of bytes) binary += String.fromCharCode(byte);
73
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
74
+ }
75
+ function fromBase64url(text) {
76
+ const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
77
+ const bytes = new Uint8Array(binary.length);
78
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
79
+ return bytes;
80
+ }
81
+ //#endregion
82
+ //#region src/config.ts
83
+ /** Milliseconds a request to Synq may take before it's given up: shorter than any lock a store holds for a refresh. */
84
+ const REQUEST_TIMEOUT_MS = 1e4;
85
+ /** Variables that say the app may run on more than one server process: serverless functions, replicas. */
86
+ const MULTI_PROCESS = [
87
+ "VERCEL",
88
+ "AWS_LAMBDA_FUNCTION_NAME",
89
+ "NETLIFY",
90
+ "K_SERVICE",
91
+ "KUBERNETES_SERVICE_HOST"
92
+ ];
93
+ /** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
94
+ function resolveConfig(options = {}, env = processEnv()) {
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);
160
+ const sessionSecret = required(options.sessionSecret ?? env.SYNQ_SESSION_SECRET, "SYNQ_SESSION_SECRET");
161
+ const previousSessionSecrets = options.previousSessionSecrets ?? (env.SYNQ_SESSION_SECRET_PREVIOUS ?? "").split(",").map((secret) => secret.trim()).filter(Boolean);
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\``);
163
+ const hint = MULTI_PROCESS.find((name) => env[name]);
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
+ }
169
+ const basePath = normalizePath(options.basePath ?? "/api/auth");
170
+ const accountApi = fields.account === false ? void 0 : `${issuer}/me`;
171
+ const primary = options.resource ?? accountApi;
172
+ const scopes = /* @__PURE__ */ new Set([
173
+ "openid",
174
+ "profile",
175
+ "email",
176
+ "offline_access"
177
+ ]);
178
+ if (accountApi) for (const scope of ["me:read", "me:write"]) scopes.add(scope);
179
+ for (const scope of (options.scope ?? "").split(/\s+/)) if (scope) scopes.add(scope);
180
+ const origin = appUrl.origin;
181
+ return {
182
+ issuer,
183
+ clientId,
184
+ clientSecret,
185
+ appUrl,
186
+ sessionSecret,
187
+ previousSessionSecrets,
188
+ basePath,
189
+ primary,
190
+ accountApi,
191
+ scope: [...scopes].join(" "),
192
+ redirectUri: `${origin}${basePath}/callback`,
193
+ postLogoutRedirectUri: fields.postLogoutRedirectUri ?? `${origin}/`,
194
+ store: options.store && fields.storePrefix ? prefixedStore(options.store, fields.storePrefix) : options.store,
195
+ sessionMaxAge: options.sessionMaxAge ?? 2592e3,
196
+ refreshLeewaySeconds: options.refreshLeewaySeconds ?? 30,
197
+ plugins: options.plugins ?? [],
198
+ fetch: options.fetch,
199
+ clientAddress: options.clientAddress ?? ((request) => request.headers.get("x-real-ip")?.trim() || void 0),
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)
215
+ };
216
+ }
217
+ function processEnv() {
218
+ return globalThis.process?.env ?? {};
219
+ }
220
+ function required(value, name) {
221
+ if (!value) throw new SynqError("invalid_options", `${name} is missing`);
222
+ return value;
223
+ }
224
+ /** An https URL, or http on this machine only. */
225
+ function checkUrl(value, name) {
226
+ let url;
227
+ try {
228
+ url = new URL(value);
229
+ } catch {
230
+ throw new SynqError("invalid_options", `${name} isn't a URL`);
231
+ }
232
+ const loopback = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
233
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new SynqError("invalid_options", `${name} must be https (http only on this machine)`);
234
+ return url;
235
+ }
236
+ /** The route mount's path, `/api/auth` style; throws `invalid_options` for anything else. */
237
+ function normalizePath(path) {
238
+ const trimmed = `/${path.replace(/^\/+|\/+$/g, "")}`;
239
+ if (!/^\/[A-Za-z0-9._~\-/]*$/.test(trimmed) || trimmed.includes("//")) throw new SynqError("invalid_options", "basePath must be a plain path, such as /api/auth");
240
+ return trimmed;
241
+ }
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
253
+ //#region src/cookies.ts
254
+ /** Characters per chunk, under the 4096 bytes a browser keeps for one cookie with its attributes. */
255
+ const CHUNK_SIZE = 3800;
256
+ /** A cookie's full name: `__Host-<base>` over https, `<base>` on a plain-http stack on this machine. */
257
+ function cookieName(base, secure) {
258
+ return secure ? `__Host-${base}` : base;
259
+ }
260
+ /** A value kept under `name`, joined from its chunks; undefined when there is none. */
261
+ function readChunked(jar, name) {
262
+ const whole = jar.get(name);
263
+ if (whole !== void 0) return whole;
264
+ let value = "";
265
+ for (let i = 0;; i++) {
266
+ const chunk = jar.get(`${name}.${i}`);
267
+ if (chunk === void 0) return i === 0 ? void 0 : value;
268
+ value += chunk;
269
+ }
270
+ }
271
+ /** Keeps `value` under `name`, in chunks when it needs them, and deletes chunks a longer value left behind. */
272
+ function writeChunked(jar, name, value, options) {
273
+ const existing = chunkNames(jar, name);
274
+ if (value.length <= 3800) {
275
+ jar.set(name, value, options);
276
+ for (const old of existing) if (old !== name) jar.delete(old, options);
277
+ return;
278
+ }
279
+ const written = /* @__PURE__ */ new Set();
280
+ for (let i = 0; i * CHUNK_SIZE < value.length; i++) {
281
+ const chunk = `${name}.${i}`;
282
+ jar.set(chunk, value.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE), options);
283
+ written.add(chunk);
284
+ }
285
+ for (const old of existing) if (!written.has(old)) jar.delete(old, options);
286
+ }
287
+ /** Deletes `name` and every chunk of it. */
288
+ function deleteChunked(jar, name, options) {
289
+ for (const old of chunkNames(jar, name)) jar.delete(old, options);
290
+ }
291
+ function chunkNames(jar, name) {
292
+ const pattern = new RegExp(`^${escapeRegExp(name)}(\\.\\d+)?$`);
293
+ return [...jar.names()].filter((candidate) => pattern.test(candidate));
294
+ }
295
+ /** The Set-Cookie header for a cookie: `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` over https. */
296
+ function setCookieHeader(name, value, options) {
297
+ const parts = [
298
+ `${name}=${value}`,
299
+ "Path=/",
300
+ "HttpOnly",
301
+ "SameSite=Lax"
302
+ ];
303
+ if (options.maxAge !== void 0) parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
304
+ if (options.secure) parts.push("Secure");
305
+ return parts.join("; ");
306
+ }
307
+ /** The cookies a `Cookie` header carries. */
308
+ function parseCookieHeader(header) {
309
+ const cookies = /* @__PURE__ */ new Map();
310
+ for (const pair of (header ?? "").split(";")) {
311
+ const at = pair.indexOf("=");
312
+ if (at <= 0) continue;
313
+ const name = pair.slice(0, at).trim();
314
+ if (!cookies.has(name)) cookies.set(name, pair.slice(at + 1).trim());
315
+ }
316
+ return cookies;
317
+ }
318
+ /** A jar over a request's cookies that collects the Set-Cookie headers its response sends. */
319
+ function requestJar(request) {
320
+ const cookies = parseCookieHeader(request.headers.get("cookie"));
321
+ const out = /* @__PURE__ */ new Map();
322
+ return {
323
+ get: (name) => cookies.get(name),
324
+ names: () => cookies.keys(),
325
+ set(name, value, options) {
326
+ cookies.set(name, value);
327
+ out.set(name, setCookieHeader(name, value, options));
328
+ },
329
+ delete(name, options) {
330
+ cookies.delete(name);
331
+ out.set(name, setCookieHeader(name, "", {
332
+ ...options,
333
+ maxAge: 0
334
+ }));
335
+ },
336
+ headers: () => [...out.values()],
337
+ /** The `Cookie` header the request carries now, changes made: for what runs after Next's proxy. */
338
+ cookieHeader: () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")
339
+ };
340
+ }
341
+ function escapeRegExp(text) {
342
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
343
+ }
344
+ //#endregion
345
+ //#region src/plugins.ts
346
+ const PLUGIN_ID = /^[a-z][a-z0-9-]{0,39}$/;
347
+ const ROUTE_KEY = /^(GET|POST|PATCH|DELETE) \/[A-Za-z0-9._~\-/]*$/;
348
+ /** Declares a server plugin, checking its id and routes once, when the module loads. */
349
+ function defineSynqPlugin(plugin) {
350
+ if (!PLUGIN_ID.test(plugin.id) || RESERVED.has(plugin.id)) throw new TypeError(`A Synq plugin's id must be lowercase letters, digits and dashes, and not one of the mount's own routes: ${plugin.id}`);
351
+ for (const key of Object.keys(plugin.routes ?? {})) if (!ROUTE_KEY.test(key)) throw new TypeError(`A Synq plugin's route is "<METHOD> /<path>": ${key}`);
352
+ return plugin;
353
+ }
354
+ /** The route mount's own paths, which no plugin can take. */
355
+ const RESERVED = /* @__PURE__ */ new Set([
356
+ "sign-in",
357
+ "callback",
358
+ "sign-out",
359
+ "session",
360
+ "account",
361
+ "link"
362
+ ]);
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
410
+ //#region src/session-name.ts
411
+ const SESSION_COOKIE = "synq.session";
412
+ //#endregion
413
+ //#region src/store.ts
414
+ /**
415
+ * A store in this process's memory: for tests and a single server process,
416
+ * where the default cookie does as well.
417
+ */
418
+ function memorySessionStore() {
419
+ const sessions = /* @__PURE__ */ new Map();
420
+ const locks = /* @__PURE__ */ new Map();
421
+ return {
422
+ get(id) {
423
+ const entry = sessions.get(id);
424
+ if (!entry || entry.expires <= Date.now()) {
425
+ sessions.delete(id);
426
+ return Promise.resolve(null);
427
+ }
428
+ return Promise.resolve({ ...entry.values });
429
+ },
430
+ set(id, values, maxAgeSeconds) {
431
+ sessions.set(id, {
432
+ values: { ...values },
433
+ expires: Date.now() + maxAgeSeconds * 1e3
434
+ });
435
+ return Promise.resolve();
436
+ },
437
+ delete(id) {
438
+ sessions.delete(id);
439
+ return Promise.resolve();
440
+ },
441
+ lock: (id, fn) => chain(locks, id, fn)
442
+ };
443
+ }
444
+ /**
445
+ * Sessions in a key-value store every server process shares, such as Redis
446
+ * or Valkey: the store an app on several processes (replicas, serverless
447
+ * functions) needs. Its lock is `SET … NX PX` with a token, polled until
448
+ * free, and released only by its holder; it expires on its own, so a dead
449
+ * process never holds a session for ever.
450
+ *
451
+ * ```ts
452
+ * keyValueSessionStore({
453
+ * get: (key) => redis.get(key),
454
+ * set: async (key, value, { ttlMs, onlyIfAbsent }) =>
455
+ * (await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
456
+ * delete: async (key) => void (await redis.del(key)),
457
+ * })
458
+ * ```
459
+ */
460
+ function keyValueSessionStore(kv, options = {}) {
461
+ const prefix = options.prefix ?? "synq:";
462
+ const lockTtlMs = options.lockTtlMs ?? 3e4;
463
+ const lockWaitMs = options.lockWaitMs ?? 35e3;
464
+ if (lockTtlMs <= 2e4) throw new TypeError(`lockTtlMs must be over ${REQUEST_TIMEOUT_MS * 2}: a refresh can make two requests to Synq (discovery, then the token) of up to ${REQUEST_TIMEOUT_MS} ms each`);
465
+ const sessionKey = (id) => `${prefix}session:${id}`;
466
+ const lockKey = (id) => `${prefix}lock:${id}`;
467
+ return {
468
+ async get(id) {
469
+ const raw = await kv.get(sessionKey(id));
470
+ if (!raw) return null;
471
+ try {
472
+ return JSON.parse(raw);
473
+ } catch {
474
+ return null;
475
+ }
476
+ },
477
+ async set(id, values, maxAgeSeconds) {
478
+ await kv.set(sessionKey(id), JSON.stringify(values), { ttlMs: maxAgeSeconds * 1e3 });
479
+ },
480
+ async delete(id) {
481
+ await kv.delete(sessionKey(id));
482
+ },
483
+ async lock(id, fn) {
484
+ const key = lockKey(id);
485
+ const token = crypto.randomUUID();
486
+ const deadline = Date.now() + lockWaitMs;
487
+ let wait = 25;
488
+ while (!await kv.set(key, token, {
489
+ ttlMs: lockTtlMs,
490
+ onlyIfAbsent: true
491
+ })) {
492
+ if (Date.now() >= deadline) throw new SynqError("lock_timeout", "Another request held this session's lock too long");
493
+ await new Promise((resolve) => setTimeout(resolve, wait));
494
+ wait = Math.min(wait * 2, 250);
495
+ }
496
+ try {
497
+ return await fn();
498
+ } finally {
499
+ if (kv.deleteIfEquals) await kv.deleteIfEquals(key, token);
500
+ else if (await kv.get(key) === token) await kv.delete(key);
501
+ }
502
+ }
503
+ };
504
+ }
505
+ /** Runs `fn` after every earlier holder of `name` in `locks`, failed or not. */
506
+ function chain(locks, name, fn) {
507
+ const run = (locks.get(name) ?? Promise.resolve()).then(fn, fn);
508
+ const settled = run.then(() => void 0, () => void 0);
509
+ locks.set(name, settled);
510
+ settled.then(() => {
511
+ if (locks.get(name) === settled) locks.delete(name);
512
+ });
513
+ return run;
514
+ }
515
+ //#endregion
516
+ //#region src/session.ts
517
+ /**
518
+ * @synqauth/js's keys in its store (client.ts), which the adapter maps onto
519
+ * a session's values: the session per API, and the refresh token.
520
+ */
521
+ const JS_SESSION_KEY = "synq.session";
522
+ const JS_REFRESH_KEY = "synq.refresh";
523
+ const REFRESH = "refresh";
524
+ const SHARED = Symbol.for("@synqauth/nextjs sessions");
525
+ const { newest, locks } = globalThis[SHARED] ??= {
526
+ newest: /* @__PURE__ */ new Map(),
527
+ locks: /* @__PURE__ */ new Map()
528
+ };
529
+ const OVERLAY_MAX = 1e4;
530
+ const OVERLAY_TTL_MS = 18e5;
531
+ var SessionHandle = class {
532
+ id;
533
+ created;
534
+ #backend;
535
+ /** Whether the cookie must be written: a new session, or values newer than the cookie's. */
536
+ #cookieRev;
537
+ #fresh;
538
+ constructor(id, created, backend, cookieRev, fresh) {
539
+ this.id = id;
540
+ this.created = created;
541
+ this.#backend = backend;
542
+ this.#cookieRev = cookieRev;
543
+ this.#fresh = fresh;
544
+ }
545
+ read() {
546
+ return this.#backend.read();
547
+ }
548
+ /** `@synqauth/js`'s store for this session's token for `resource`; `primary` is the API whose session holds the ID token. */
549
+ storage(resource, primary) {
550
+ const own = tokenKey(resource);
551
+ const main = tokenKey(primary);
552
+ const backend = this.#backend;
553
+ return {
554
+ holdsRefreshTokens: true,
555
+ async get(key) {
556
+ const values = await backend.read();
557
+ if (key === JS_REFRESH_KEY) return values[REFRESH];
558
+ if (key !== JS_SESSION_KEY) return void 0;
559
+ const mine = values[own];
560
+ const first = values[main];
561
+ if (own === main) return mine;
562
+ if (!first) return mine;
563
+ const { idToken, claims } = JSON.parse(first);
564
+ if (mine) return JSON.stringify({
565
+ ...JSON.parse(mine),
566
+ idToken,
567
+ claims
568
+ });
569
+ return JSON.stringify({
570
+ accessToken: "",
571
+ tokenType: "Bearer",
572
+ expiresAt: 0,
573
+ idToken,
574
+ claims
575
+ });
576
+ },
577
+ async set(key, value) {
578
+ if (key === JS_REFRESH_KEY) return backend.write((values) => void (values[REFRESH] = value));
579
+ if (key !== JS_SESSION_KEY) return;
580
+ let kept = value;
581
+ if (own !== main) {
582
+ const rest = JSON.parse(value);
583
+ delete rest.idToken;
584
+ delete rest.claims;
585
+ kept = JSON.stringify(rest);
586
+ }
587
+ await backend.write((values) => void (values[own] = kept));
588
+ },
589
+ async remove(key) {
590
+ if (key === JS_REFRESH_KEY) return backend.write((values) => void delete values[REFRESH]);
591
+ if (key !== JS_SESSION_KEY) return;
592
+ await backend.write((values) => {
593
+ if (own === main) for (const name of Object.keys(values)) delete values[name];
594
+ else delete values[own];
595
+ });
596
+ },
597
+ lock: (_name, fn) => backend.lock(fn)
598
+ };
599
+ }
600
+ /** Writes the session's cookie when it's new or this process holds newer values than it. */
601
+ async commit(jar, keys, secure, maxAge, store) {
602
+ const name = cookieName(SESSION_COOKIE, secure);
603
+ const remaining = Math.max(0, this.created + maxAge - now$1());
604
+ if (store) {
605
+ if (!this.#fresh) return;
606
+ const sealed = {
607
+ v: 1,
608
+ id: this.id,
609
+ created: this.created
610
+ };
611
+ writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
612
+ secure,
613
+ maxAge: remaining
614
+ });
615
+ this.#fresh = false;
616
+ return;
617
+ }
618
+ const current = newest.get(this.id);
619
+ const rev = current?.rev ?? 0;
620
+ if (!this.#fresh && rev <= this.#cookieRev) return;
621
+ const sealed = {
622
+ v: 1,
623
+ id: this.id,
624
+ created: this.created,
625
+ rev,
626
+ values: current?.values ?? {}
627
+ };
628
+ writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
629
+ secure,
630
+ maxAge: remaining
631
+ });
632
+ this.#cookieRev = rev;
633
+ this.#fresh = false;
634
+ }
635
+ };
636
+ /** The session the request's cookie names, or null: none, unreadable, past its age, or gone from the store. */
637
+ async function loadSession(jar, keys, secure, maxAge, store) {
638
+ const name = cookieName(SESSION_COOKIE, secure);
639
+ const opened = await unseal(keys.open, readChunked(jar, name), name);
640
+ if (!opened) return null;
641
+ let sealed;
642
+ try {
643
+ sealed = JSON.parse(opened);
644
+ } catch {
645
+ return null;
646
+ }
647
+ if (sealed.v !== 1 || typeof sealed.id !== "string" || sealed.created + maxAge <= now$1()) return null;
648
+ if (store) {
649
+ if (!await store.get(sealed.id)) return null;
650
+ return new SessionHandle(sealed.id, sealed.created, storeBackend(store, sealed.id, sealed.created, maxAge), 0, false);
651
+ }
652
+ const rev = sealed.rev ?? 0;
653
+ remember(sealed.id, rev, sealed.values ?? {});
654
+ return new SessionHandle(sealed.id, sealed.created, cookieBackend(sealed.id), rev, false);
655
+ }
656
+ /** A new, empty session: what a sign-in fills. */
657
+ function newSession(maxAge, store) {
658
+ const id = randomId();
659
+ const created = now$1();
660
+ if (store) return new SessionHandle(id, created, storeBackend(store, id, created, maxAge), 0, true);
661
+ remember(id, 0, {});
662
+ return new SessionHandle(id, created, cookieBackend(id), -1, true);
663
+ }
664
+ /** Ends a session: its cookie, its stored values, and what this process remembers of it. */
665
+ async function endSession(jar, handle, secure, store) {
666
+ if (handle) {
667
+ newest.delete(handle.id);
668
+ if (store) await store.delete(handle.id);
669
+ }
670
+ deleteChunked(jar, cookieName(SESSION_COOKIE, secure), { secure });
671
+ }
672
+ /** The key a session keeps `resource`'s token under. */
673
+ function tokenKey(resource) {
674
+ return `token:${resource ?? ""}`;
675
+ }
676
+ function cookieBackend(id) {
677
+ return {
678
+ read() {
679
+ const entry = newest.get(id);
680
+ return Promise.resolve({ ...entry?.values ?? {} });
681
+ },
682
+ write(mutate) {
683
+ const entry = newest.get(id);
684
+ const values = { ...entry?.values ?? {} };
685
+ mutate(values);
686
+ remember(id, (entry?.rev ?? 0) + 1, values);
687
+ return Promise.resolve();
688
+ },
689
+ lock: (fn) => chain(locks, id, fn)
690
+ };
691
+ }
692
+ function storeBackend(store, id, created, maxAge) {
693
+ return {
694
+ async read() {
695
+ return await store.get(id) ?? {};
696
+ },
697
+ async write(mutate) {
698
+ const values = await store.get(id) ?? {};
699
+ mutate(values);
700
+ await store.set(id, values, Math.max(1, created + maxAge - now$1()));
701
+ },
702
+ lock: (fn) => store.lock(id, fn)
703
+ };
704
+ }
705
+ /** Keeps the newer of what's remembered and `rev`'s values. */
706
+ function remember(id, rev, values) {
707
+ const entry = newest.get(id);
708
+ const time = Date.now();
709
+ if (entry && entry.rev >= rev) {
710
+ entry.seen = time;
711
+ return;
712
+ }
713
+ newest.delete(id);
714
+ newest.set(id, {
715
+ rev,
716
+ values,
717
+ seen: time
718
+ });
719
+ if (newest.size > OVERLAY_MAX) for (const [old, kept] of newest) {
720
+ if (newest.size <= OVERLAY_MAX && time - kept.seen < OVERLAY_TTL_MS) break;
721
+ newest.delete(old);
722
+ }
723
+ }
724
+ function now$1() {
725
+ return Math.floor(Date.now() / 1e3);
726
+ }
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
870
+ //#region src/core.ts
871
+ const PROMPTS = /* @__PURE__ */ new Set([
872
+ "login",
873
+ "none",
874
+ "consent",
875
+ "select_account",
876
+ "create"
877
+ ]);
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}$/;
882
+ /** A sign-in method's name, as `synq_method` and the account API's link requests take it. */
883
+ const METHOD = /^[a-z0-9][a-z0-9_-]{0,63}$/;
884
+ const TRANSACTION_SECONDS = 3600;
885
+ const TRANSACTION_PREFIX = "synq.tx.";
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
+ ]);
896
+ const LINK_BODY_LIMIT = 4096;
897
+ /** A step of an own-UI sign-in: a passkey's assertion is the most it carries. */
898
+ const FLOW_BODY_LIMIT = 16384;
899
+ /** Where an own-UI sign-in's flow is kept, sealed: one a browser at a time. */
900
+ const FLOW_KEY = `${TRANSACTION_PREFIX}flow`;
901
+ /** `synq_siws` as @synqauth/solana's `encodeSiws` writes it: base64url, well under the body's limit. */
902
+ const SIWS = /^[A-Za-z0-9_-]{1,3072}$/;
903
+ /** Claims of the ID token that say nothing about the person. */
904
+ const PROTOCOL_CLAIMS = /* @__PURE__ */ new Set([
905
+ "iss",
906
+ "aud",
907
+ "azp",
908
+ "exp",
909
+ "iat",
910
+ "nbf",
911
+ "nonce",
912
+ "at_hash",
913
+ "c_hash",
914
+ "sid",
915
+ "jti"
916
+ ]);
917
+ /**
918
+ * The framework-neutral core `synqAuth()` runs on, over any cookie jar: for
919
+ * a server that answers the same routes without Next. Most apps use
920
+ * `synqAuth()` instead.
921
+ */
922
+ function createSynqCore(config) {
923
+ /** Whether the missing end-user address was logged already: once is enough. */
924
+ let warnedAboutAddress = false;
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);
928
+ /** Every request to Synq: never cached, and given up after REQUEST_TIMEOUT_MS. */
929
+ const upstream = (input, init = {}) => {
930
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
931
+ return (config.fetch ?? fetch)(input, {
932
+ ...init,
933
+ cache: "no-store",
934
+ signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout
935
+ });
936
+ };
937
+ function client(storage, resource, transactions) {
938
+ return createSynqClient({
939
+ issuer: config.issuer,
940
+ clientId: config.clientId,
941
+ clientSecret: config.clientSecret,
942
+ redirectUri: config.redirectUri,
943
+ postLogoutRedirectUri: config.postLogoutRedirectUri,
944
+ scope: config.scope,
945
+ resource,
946
+ storage,
947
+ transactionStorage: transactions ?? memoryStorage(),
948
+ fetch: upstream,
949
+ refreshLeewaySeconds: config.refreshLeewaySeconds
950
+ });
951
+ }
952
+ async function load(jar) {
953
+ return loadSession(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
954
+ }
955
+ async function commit(jar, handle) {
956
+ await handle.commit(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
957
+ }
958
+ /** Who signed in (the ID token's `sub`), and in which session at the issuer (its `sid`, when Synq sent one). */
959
+ async function issuerSessionOf(handle) {
960
+ const raw = (await handle.read())[tokenKey(config.primary)];
961
+ if (!raw) return void 0;
962
+ const { claims } = JSON.parse(raw);
963
+ if (typeof claims?.sub !== "string") return void 0;
964
+ return {
965
+ sub: claims.sub,
966
+ sid: typeof claims.sid === "string" ? claims.sid : void 0
967
+ };
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
+ }
1006
+ /** The person the session's main token was issued with, while the session can still give a token. */
1007
+ async function userOf(handle) {
1008
+ if (!handle) return {
1009
+ user: null,
1010
+ expiresAt: null
1011
+ };
1012
+ const values = await handle.read();
1013
+ const raw = values[tokenKey(config.primary)];
1014
+ if (!raw) return {
1015
+ user: null,
1016
+ expiresAt: null
1017
+ };
1018
+ const stored = JSON.parse(raw);
1019
+ const expiresAt = stored.expiresAt ?? null;
1020
+ if (!values.refresh && expiresAt !== null && expiresAt <= now()) return {
1021
+ user: null,
1022
+ expiresAt: null
1023
+ };
1024
+ if (!stored.claims || typeof stored.claims.sub !== "string") return {
1025
+ user: null,
1026
+ expiresAt
1027
+ };
1028
+ const user = {};
1029
+ for (const [name, value] of Object.entries(stored.claims)) if (!PROTOCOL_CLAIMS.has(name)) user[name] = value;
1030
+ return {
1031
+ user,
1032
+ expiresAt
1033
+ };
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
+ }
1077
+ async function getAccessToken(jar, options = {}) {
1078
+ const resource = options.resource ?? config.primary;
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`);
1080
+ const handle = await load(jar);
1081
+ if (!handle) throw new SynqError("not_signed_in", "Nobody is signed in");
1082
+ if (!config.store && jar.writable?.() === false) {
1083
+ const raw = (await handle.read())[tokenKey(resource)];
1084
+ const stored = raw ? JSON.parse(raw) : {};
1085
+ if (stored.accessToken && (stored.expiresAt === void 0 || stored.expiresAt > now())) return stored.accessToken;
1086
+ throw new SynqError("refresh_required", "The session's token ran out where its cookie can't be written (a server component): let synqProxy() refresh it before the page renders");
1087
+ }
1088
+ try {
1089
+ const token = await client(handle.storage(resource, config.primary), resource).getAccessToken({ minValiditySeconds: options.minValiditySeconds });
1090
+ await commit(jar, handle);
1091
+ return token;
1092
+ } catch (error) {
1093
+ if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) await endSession(jar, handle, config.secure, config.store);
1094
+ throw error;
1095
+ }
1096
+ }
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) {
1105
+ const keys = await keysFor();
1106
+ const options = {
1107
+ secure: config.secure,
1108
+ maxAge: TRANSACTION_SECONDS
1109
+ };
1110
+ const found = {};
1111
+ return {
1112
+ storage: {
1113
+ async get(name) {
1114
+ const cookie = cookieName(name, config.secure);
1115
+ const opened = await unseal(keys.open, jar.get(cookie), cookie);
1116
+ if (!opened) return void 0;
1117
+ const { v, r, p, s, l } = JSON.parse(opened);
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;
1122
+ return v;
1123
+ },
1124
+ async set(name, value) {
1125
+ await dropOldTransactions(jar, keys.open);
1126
+ const cookie = cookieName(name, config.secure);
1127
+ const sealed = JSON.stringify({
1128
+ v: value,
1129
+ r: returnTo,
1130
+ t: Date.now(),
1131
+ p: popup,
1132
+ s: reauthenticate,
1133
+ ...link ? { l: true } : {}
1134
+ });
1135
+ jar.set(cookie, await seal(keys.seal, sealed, cookie), options);
1136
+ },
1137
+ remove(name) {
1138
+ jar.delete(cookieName(name, config.secure), options);
1139
+ }
1140
+ },
1141
+ found
1142
+ };
1143
+ }
1144
+ /** Keeps a browser's sign-ins under way to MAX_TRANSACTIONS, the newest, before it starts another. */
1145
+ async function dropOldTransactions(jar, keys) {
1146
+ const prefix = cookieName(TRANSACTION_PREFIX, config.secure);
1147
+ const started = [];
1148
+ for (const name of [...jar.names()]) {
1149
+ if (!name.startsWith(prefix)) continue;
1150
+ const opened = await unseal(keys, jar.get(name), name);
1151
+ let at;
1152
+ try {
1153
+ at = opened ? JSON.parse(opened).t ?? 0 : 0;
1154
+ } catch {
1155
+ at = 0;
1156
+ }
1157
+ started.push({
1158
+ name,
1159
+ at,
1160
+ order: started.length
1161
+ });
1162
+ }
1163
+ started.sort((a, b) => b.at - a.at || b.order - a.order);
1164
+ for (const { name } of started.slice(4)) jar.delete(name, { secure: config.secure });
1165
+ }
1166
+ async function signIn(request, jar) {
1167
+ if (isPrefetch(request)) return new Response(null, { status: 204 });
1168
+ const params = new URL(request.url).searchParams;
1169
+ const returnTo = safeReturnTo(params.get("returnTo"), config.appUrl);
1170
+ const prompt = params.get("prompt") ?? void 0;
1171
+ const maxAge = params.get("max_age");
1172
+ const intent = params.get("intent") ?? void 0;
1173
+ if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent, select_account or create");
1174
+ if (maxAge !== null && !/^\d{1,9}$/.test(maxAge)) return problem(400, "invalid_request", "max_age is a number of seconds");
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);
1201
+ return redirect((await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
1202
+ method: params.get("method") ?? void 0,
1203
+ loginHint: params.get("login_hint") ?? void 0,
1204
+ prompt: reauthenticate ? "login" : intentPrompt(intent, prompt),
1205
+ maxAge: maxAge === null ? void 0 : Number(maxAge),
1206
+ additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
1207
+ params: {
1208
+ ...intent ? { synq_intent: intent } : {},
1209
+ ...plan ? { synq_plan: plan } : {},
1210
+ ...interval ? { synq_interval: interval } : {},
1211
+ ...onboarding ? { synq_onboarding: onboarding } : {}
1212
+ }
1213
+ })).href, 302);
1214
+ }
1215
+ async function callback(request, jar) {
1216
+ const tx = await transactions(jar);
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);
1224
+ try {
1225
+ await client(handle.storage(config.primary, config.primary), config.primary, tx.storage).handleCallback(request.url);
1226
+ } catch (error) {
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
+ }
1235
+ }
1236
+ await startSession(jar, handle);
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);
1243
+ }
1244
+ async function signOut(request, jar) {
1245
+ const stay = await staysOnPage(request);
1246
+ if (stay instanceof Response) return stay;
1247
+ const handle = await load(jar);
1248
+ let url = null;
1249
+ if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut(stay ? { stay: true } : {});
1250
+ await endSession(jar, handle, config.secure, config.store);
1251
+ forgetEvent(jar, config.secure);
1252
+ if (stay) return json({ redirect: null });
1253
+ const target = url?.href ?? config.postLogoutRedirectUri;
1254
+ if ((request.headers.get("accept") ?? "").includes("application/json")) return json({ redirect: target });
1255
+ return redirect(target, 303);
1256
+ }
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
+ /**
1283
+ * Adding a way to sign in (AERO-1397): the account API makes a link request
1284
+ * for the person with the session's token, and its ticket goes to Synq in
1285
+ * a sign-in like any other, which asks them to sign in again first. A POST
1286
+ * from the app's own pages, answered `{ redirect }` for the browser to
1287
+ * follow, so another site can't start one.
1288
+ */
1289
+ async function link(request, jar) {
1290
+ if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
1291
+ const length = Number(request.headers.get("content-length") ?? 0);
1292
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
1293
+ if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1294
+ let input;
1295
+ try {
1296
+ const body = await request.text();
1297
+ if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1298
+ input = JSON.parse(body);
1299
+ } catch {
1300
+ return problem(400, "invalid_request", "Send { method, returnTo } as JSON");
1301
+ }
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;
1305
+ const returnTo = safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl);
1306
+ let token;
1307
+ try {
1308
+ token = await getAccessToken(jar, { resource: config.accountApi });
1309
+ } catch (error) {
1310
+ if (isSynqError(error) && [
1311
+ "not_signed_in",
1312
+ "invalid_grant",
1313
+ "no_refresh_token"
1314
+ ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
1315
+ throw error;
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
+ }
1334
+ const answer = await upstream(`${config.accountApi}/v1/link-requests`, {
1335
+ method: "POST",
1336
+ headers: {
1337
+ authorization: `Bearer ${token}`,
1338
+ accept: "application/json",
1339
+ "content-type": "application/json"
1340
+ },
1341
+ body: JSON.stringify({ method: input.method })
1342
+ });
1343
+ if (!answer.ok) return new Response(answer.body, {
1344
+ status: answer.status,
1345
+ headers: { "content-type": answer.headers.get("content-type") ?? "application/problem+json" }
1346
+ });
1347
+ const ticket = (await answer.json()).authorization_params?.synq_link;
1348
+ if (typeof ticket !== "string") return problem(502, "invalid_response", "Synq answered without a link ticket");
1349
+ const tx = await transactions(jar, returnTo, display, void 0, true);
1350
+ return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
1351
+ prompt: "login",
1352
+ additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
1353
+ params: {
1354
+ synq_intent: "link",
1355
+ synq_link: ticket
1356
+ }
1357
+ })).href });
1358
+ }
1359
+ /**
1360
+ * Signing in with a wallet from the app's own page (AERO-1423, over the
1361
+ * issuer's headless wallet sign-in, AERO-1410): `{ siws, returnTo }`, the
1362
+ * `synq_siws` @synqauth/solana's `encodeSiws` makes of what the wallet
1363
+ * signed. It goes to Synq in a pushed authorization request with
1364
+ * `prompt=login`, sent from this server with the app's credentials, so the
1365
+ * signature never lands in a URL; answered `{ redirect }` for the browser
1366
+ * to follow. A POST from the app's own pages.
1367
+ */
1368
+ async function walletSignIn(request, jar) {
1369
+ const length = Number(request.headers.get("content-length") ?? 0);
1370
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
1371
+ if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1372
+ let input;
1373
+ try {
1374
+ const body = await request.text();
1375
+ if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1376
+ input = JSON.parse(body);
1377
+ } catch {
1378
+ return problem(400, "invalid_request", "Send { siws, returnTo } as JSON");
1379
+ }
1380
+ if (typeof input?.siws !== "string" || !SIWS.test(input.siws)) return problem(400, "invalid_request", "siws is what the wallet signed, as encodeSiws makes it");
1381
+ const tx = await transactions(jar, safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl));
1382
+ return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
1383
+ prompt: "login",
1384
+ pushed: true,
1385
+ additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
1386
+ params: { synq_siws: input.siws }
1387
+ })).href });
1388
+ }
1389
+ /**
1390
+ * One step of a sign-in on the app's own screens (AERO-1411): the page
1391
+ * posts what the person typed, `{ step, … }`, and this server takes it to
1392
+ * Synq's Authorization Challenge Endpoint as the app, with where the
1393
+ * person is (`clientAddress`). The flow (its PKCE verifier and Synq's auth
1394
+ * session) stays sealed in a cookie; the page hears only what to show
1395
+ * next, never a token, a code or the auth session. A sign-in that ends
1396
+ * starts the session as the callback does.
1397
+ */
1398
+ async function signInFlow(request, jar) {
1399
+ if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return problem(415, "unsupported_media_type", "Send the step as JSON");
1400
+ const length = Number(request.headers.get("content-length") ?? 0);
1401
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
1402
+ if (!Number.isFinite(length) || length > FLOW_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1403
+ let input;
1404
+ try {
1405
+ const body = await request.text();
1406
+ if (new TextEncoder().encode(body).length > FLOW_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1407
+ input = JSON.parse(body);
1408
+ if (!input || typeof input !== "object") throw new TypeError("not an object");
1409
+ } catch {
1410
+ return problem(400, "invalid_request", "Send { step, … } as JSON");
1411
+ }
1412
+ const text = (name, max = 512) => typeof input[name] === "string" ? input[name].slice(0, max) : "";
1413
+ const step = text("step", 32);
1414
+ const returnTo = safeReturnTo(text("returnTo", 2048) || null, config.appUrl);
1415
+ const tx = await transactions(jar, returnTo);
1416
+ const saved = await tx.storage.get(FLOW_KEY);
1417
+ if (step === "restart") {
1418
+ await tx.storage.remove(FLOW_KEY);
1419
+ return json({ status: "restarted" });
1420
+ }
1421
+ const endUserIp = config.clientAddress(request);
1422
+ if (!endUserIp && !warnedAboutAddress) {
1423
+ warnedAboutAddress = true;
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");
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
+ });
1433
+ const handle = newSession(config.sessionMaxAge, config.store);
1434
+ const synq = client(handle.storage(config.primary, config.primary), config.primary);
1435
+ const flow = saved && !reauthenticate ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
1436
+ endUserIp,
1437
+ additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : []
1438
+ });
1439
+ const email = text("email", 254);
1440
+ let result;
1441
+ try {
1442
+ switch (step) {
1443
+ case "password":
1444
+ result = text("screen") === "sign-up" ? await flow.password.signUp({
1445
+ email,
1446
+ password: text("password", 1024)
1447
+ }) : await flow.password.signIn({
1448
+ email,
1449
+ password: text("password", 1024)
1450
+ });
1451
+ break;
1452
+ case "email":
1453
+ result = await flow.email.send(email);
1454
+ break;
1455
+ case "code":
1456
+ result = flow.step === "verify-email" ? await flow.verifyEmail.verify(text("code", 32)) : await flow.email.verify(text("code", 32));
1457
+ break;
1458
+ case "resend":
1459
+ result = flow.step === "verify-email" ? await flow.verifyEmail.resend() : await flow.email.resend();
1460
+ break;
1461
+ case "skip":
1462
+ result = await flow.verifyEmail.skip();
1463
+ break;
1464
+ case "passkey-options":
1465
+ result = await flow.passkey.options();
1466
+ break;
1467
+ case "passkey": {
1468
+ const credential = input.credential;
1469
+ if (!credential || typeof credential !== "object") return problem(400, "invalid_request", "credential is the passkey’s assertion");
1470
+ result = await flow.passkey.signIn(credential, { related: input.related === true });
1471
+ break;
1472
+ }
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");
1480
+ }
1481
+ } catch (error) {
1482
+ if (isSynqError(error, "invalid_session")) {
1483
+ await tx.storage.remove(FLOW_KEY);
1484
+ return json({
1485
+ status: "expired",
1486
+ message: error.message
1487
+ });
1488
+ }
1489
+ if (isSynqError(error, "too_many_requests")) return json({
1490
+ status: "retry",
1491
+ step: flow.step,
1492
+ problem: "limited",
1493
+ message: error.message,
1494
+ ...error.retryAfter === void 0 ? {} : { retryAfter: error.retryAfter }
1495
+ });
1496
+ throw error;
1497
+ }
1498
+ if (result.status === "signed-in") {
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
+ }
1513
+ await startSession(jar, handle);
1514
+ await keepSignIn(jar, handle, { event: !reauthenticate });
1515
+ return json({
1516
+ status: "signed-in",
1517
+ returnTo: tx.found.returnTo ?? returnTo
1518
+ });
1519
+ }
1520
+ if (flow.step === "done") await tx.storage.remove(FLOW_KEY);
1521
+ else await tx.storage.set(FLOW_KEY, JSON.stringify(flow));
1522
+ switch (result.status) {
1523
+ case "code-sent": return json({
1524
+ status: result.status,
1525
+ step: flow.step,
1526
+ email: result.email,
1527
+ expiresAt: result.expiresAt.toISOString()
1528
+ });
1529
+ case "verify-email": return json({
1530
+ status: result.status,
1531
+ step: flow.step,
1532
+ email: result.email,
1533
+ canSkip: result.canSkip
1534
+ });
1535
+ case "passkey": return json({
1536
+ status: result.status,
1537
+ step: flow.step,
1538
+ passkey: {
1539
+ options: result.options,
1540
+ related: result.related
1541
+ },
1542
+ ...result.problem ? {
1543
+ problem: result.problem,
1544
+ message: result.message
1545
+ } : {}
1546
+ });
1547
+ case "retry": return json({
1548
+ status: result.status,
1549
+ step: flow.step,
1550
+ problem: result.problem,
1551
+ message: result.message,
1552
+ ...result.retryAfter === void 0 ? {} : { retryAfter: result.retryAfter }
1553
+ });
1554
+ case "hosted": return json({
1555
+ status: result.status,
1556
+ hosted: {
1557
+ reason: result.reason,
1558
+ ...result.method ? { method: result.method } : {},
1559
+ ...result.loginHint ? { loginHint: result.loginHint } : {}
1560
+ }
1561
+ });
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
+ });
1572
+ }
1573
+ }
1574
+ /**
1575
+ * Starts the session `handle` holds the new sign-in's tokens of. A
1576
+ * sign-in replaces whatever session the browser had, and its refresh
1577
+ * token is revoked: an offline grant outlives the brand's session. But
1578
+ * the same person signing in again within their session at the issuer,
1579
+ * as adding a passkey or a way in asks, gets the grant the replaced
1580
+ * session had, and Synq ends a grant with any refresh token of it
1581
+ * revoked: that one is dropped instead. Same person: the ID tokens'
1582
+ * `sub`. Same issuer session: their `sid`, which Synq includes for every
1583
+ * app (AERO-1624) but one whose tenant turned it off; without it, the
1584
+ * same `sub` is taken for the same session (a sign-in after it lapsed
1585
+ * leaves the old grant to expire).
1586
+ */
1587
+ async function startSession(jar, handle) {
1588
+ const previous = await load(jar);
1589
+ if (previous) {
1590
+ const [before, after] = await Promise.all([issuerSessionOf(previous), issuerSessionOf(handle)]);
1591
+ if (!(before !== void 0 && after !== void 0 && before.sub === after.sub && (before.sid === void 0 || after.sid === void 0 || before.sid === after.sid))) try {
1592
+ await client(previous.storage(config.primary, config.primary), config.primary).signOut();
1593
+ } catch {}
1594
+ await endSession(jar, previous, config.secure, config.store);
1595
+ }
1596
+ await commit(jar, handle);
1597
+ }
1598
+ async function account(request, jar, path) {
1599
+ if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
1600
+ if (!/^v1(\/[A-Za-z0-9._~-]+)+$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) return problem(404, "not_found", "No such account API route");
1601
+ const length = Number(request.headers.get("content-length") ?? 0);
1602
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
1603
+ if (!Number.isFinite(length) || length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1604
+ let token;
1605
+ try {
1606
+ token = await getAccessToken(jar, { resource: config.accountApi });
1607
+ } catch (error) {
1608
+ if (isSynqError(error) && [
1609
+ "not_signed_in",
1610
+ "invalid_grant",
1611
+ "no_refresh_token"
1612
+ ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
1613
+ throw error;
1614
+ }
1615
+ const headers = new Headers({
1616
+ authorization: `Bearer ${token}`,
1617
+ accept: "application/json"
1618
+ });
1619
+ for (const name of ["content-type", "idempotency-key"]) {
1620
+ const value = request.headers.get(name);
1621
+ if (value) headers.set(name, value);
1622
+ }
1623
+ let body;
1624
+ if (request.method !== "GET" && request.method !== "HEAD") {
1625
+ body = await request.text();
1626
+ if (new TextEncoder().encode(body).length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1627
+ }
1628
+ const search = new URL(request.url).search;
1629
+ const answer = await upstream(`${config.accountApi}/${path}${search}`, {
1630
+ method: request.method,
1631
+ headers,
1632
+ body,
1633
+ redirect: "manual"
1634
+ });
1635
+ const out = new Headers({ "cache-control": "no-store" });
1636
+ const type = answer.headers.get("content-type");
1637
+ if (type) out.set("content-type", type);
1638
+ return new Response(answer.status === 204 ? null : await answer.text(), {
1639
+ status: answer.status,
1640
+ headers: out
1641
+ });
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
+ }
1722
+ async function plugin(request, jar, id, rest) {
1723
+ const route = config.plugins.find((candidate) => candidate.id === id)?.routes?.[`${request.method} /${rest}`];
1724
+ if (!route) return void 0;
1725
+ return route({
1726
+ user: async () => (await userOf(await load(jar))).user,
1727
+ getAccessToken: (options) => getAccessToken(jar, options),
1728
+ issuer: config.issuer,
1729
+ clientId: config.clientId,
1730
+ basePath: config.basePath
1731
+ }, request);
1732
+ }
1733
+ async function handle(request) {
1734
+ const url = new URL(request.url);
1735
+ const prefix = `${config.basePath}/`;
1736
+ if (!url.pathname.startsWith(prefix)) return problem(404, "not_found", "Not under the route mount");
1737
+ const rest = url.pathname.slice(prefix.length);
1738
+ const [first = "", ...tail] = rest.split("/");
1739
+ const jar = requestJar(request);
1740
+ if (!["GET", "HEAD"].includes(request.method) && !sameOrigin(request, config.appUrl)) return problem(403, "forbidden", "A change must come from the app's own pages");
1741
+ let response;
1742
+ try {
1743
+ if (rest === "sign-in" && request.method === "GET") response = await signIn(request, jar);
1744
+ else if (rest === "sign-in" && request.method === "POST") response = await walletSignIn(request, jar);
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
+ });
1750
+ else if (rest === "callback" && request.method === "GET") response = await callback(request, jar);
1751
+ else if (rest === "sign-out" && request.method === "POST") response = await signOut(request, jar);
1752
+ else if (rest === "link" && request.method === "POST") response = await link(request, 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);
1759
+ else if (first === "account") response = await account(request, jar, tail.join("/"));
1760
+ else if (!RESERVED.has(first)) response = await plugin(request, jar, first, tail.join("/"));
1761
+ } catch (error) {
1762
+ if (!isSynqError(error)) throw error;
1763
+ response = problem(error.status && error.status >= 400 ? 502 : 500, error.code, error.message);
1764
+ }
1765
+ response ??= problem(404, "not_found", "No such route under the mount");
1766
+ const headers = new Headers(response.headers);
1767
+ for (const cookie of jar.headers()) headers.append("set-cookie", cookie);
1768
+ if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
1769
+ headers.set("x-content-type-options", "nosniff");
1770
+ return new Response(response.body, {
1771
+ status: response.status,
1772
+ headers
1773
+ });
1774
+ }
1775
+ async function refreshIfDue(jar) {
1776
+ const handle = await load(jar);
1777
+ const { user, expiresAt } = await userOf(handle);
1778
+ if (!handle || !user) return false;
1779
+ if (config.store || expiresAt === null || expiresAt - config.refreshLeewaySeconds > now()) return true;
1780
+ try {
1781
+ await getAccessToken(jar);
1782
+ return true;
1783
+ } catch (error) {
1784
+ if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) return false;
1785
+ return expiresAt > now();
1786
+ }
1787
+ }
1788
+ return {
1789
+ handle,
1790
+ user: async (jar) => (await userOf(await load(jar))).user,
1791
+ session: sessionInfo,
1792
+ getAccessToken,
1793
+ refreshIfDue,
1794
+ config
1795
+ };
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
+ }
1813
+ /**
1814
+ * Whether a request is a prefetch, not a person going there: Next.js's
1815
+ * router (`Next-Router-Prefetch`) and browsers' speculative loads (`Purpose`,
1816
+ * `Sec-Purpose`: prefetch or prerender).
1817
+ */
1818
+ function isPrefetch(request) {
1819
+ const h = request.headers;
1820
+ if (h.get("next-router-prefetch") !== null) return true;
1821
+ const purpose = `${h.get("purpose") ?? ""} ${h.get("sec-purpose") ?? ""}`.toLowerCase();
1822
+ return /\b(prefetch|prerender)\b/.test(purpose);
1823
+ }
1824
+ /**
1825
+ * Where to send the browser back to on the app, as an absolute URL, never
1826
+ * another site: the app's root for anything else. It checks where the value
1827
+ * resolves to, not how it's spelled, since dot segments and encodings
1828
+ * (`/.//evil.example`, `/%2e//evil.example`) collapse into another host.
1829
+ */
1830
+ function safeReturnTo(value, appUrl) {
1831
+ const home = new URL("/", appUrl).href;
1832
+ if (!value || value.includes("\\")) return home;
1833
+ try {
1834
+ const url = new URL(value, appUrl);
1835
+ return url.origin === appUrl.origin && !url.pathname.startsWith("//") ? url.href : home;
1836
+ } catch {
1837
+ return home;
1838
+ }
1839
+ }
1840
+ /** A request from the app's own pages: its `Origin`, or else the browser's `Sec-Fetch-Site`, says so. */
1841
+ function sameOrigin(request, appUrl) {
1842
+ const origin = request.headers.get("origin");
1843
+ if (origin) return origin === appUrl.origin;
1844
+ return request.headers.get("sec-fetch-site") === "same-origin";
1845
+ }
1846
+ function withError(target, code) {
1847
+ const url = new URL(target);
1848
+ url.searchParams.set("synq_error", code);
1849
+ return url.href;
1850
+ }
1851
+ function redirect(location, status) {
1852
+ return new Response(null, {
1853
+ status,
1854
+ headers: { location }
1855
+ });
1856
+ }
1857
+ function json(body, status = 200) {
1858
+ return Response.json(body, {
1859
+ status,
1860
+ headers: { "cache-control": "no-store" }
1861
+ });
1862
+ }
1863
+ function problem(status, code, detail) {
1864
+ return Response.json({
1865
+ type: "about:blank",
1866
+ status,
1867
+ code,
1868
+ detail
1869
+ }, {
1870
+ status,
1871
+ headers: {
1872
+ "content-type": "application/problem+json",
1873
+ "cache-control": "no-store"
1874
+ }
1875
+ });
1876
+ }
1877
+ function now() {
1878
+ return Math.floor(Date.now() / 1e3);
1879
+ }
1880
+ //#endregion
1881
+ //#region src/sign-in-error.ts
1882
+ /** The query parameter the route mount adds to `returnTo` when a sign-in fails. */
1883
+ const SIGN_IN_ERROR_PARAM = "synq_error";
1884
+ /** An OAuth or SDK error code, as the route mount puts it there. */
1885
+ const CODE = /^[a-z0-9_]{1,64}$/;
1886
+ const MESSAGES = {
1887
+ access_denied: "The sign-in was cancelled, or this account can’t use this app.",
1888
+ login_required: "You need to sign in to continue.",
1889
+ interaction_required: "You need to sign in to continue.",
1890
+ consent_required: "You need to allow this app to use your account to continue.",
1891
+ account_selection_required: "Choose an account to continue.",
1892
+ temporarily_unavailable: "Signing in isn’t available just now. Try again in a moment.",
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."
1896
+ };
1897
+ /** What a sign-in error code means, in plain words. */
1898
+ function signInErrorMessage(code) {
1899
+ return MESSAGES[code] ?? "Something went wrong signing in. Try again.";
1900
+ }
1901
+ /** A path and query on the app, without `synq_error`: where the ways on come back to. */
1902
+ function withoutSignInError(pathAndSearch) {
1903
+ const url = new URL(pathAndSearch, "http://app.invalid");
1904
+ url.searchParams.delete(SIGN_IN_ERROR_PARAM);
1905
+ return `${url.pathname}${url.search}`;
1906
+ }
1907
+ /**
1908
+ * The sign-in error `code` names, coming back to `returnTo` (a path on the
1909
+ * app), or null when there is none or it isn't a code.
1910
+ */
1911
+ function signInErrorOf(code, basePath, returnTo) {
1912
+ if (!code || !CODE.test(code)) return null;
1913
+ const back = withoutSignInError(returnTo.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/");
1914
+ const signIn = (prompt) => {
1915
+ const params = new URLSearchParams({ returnTo: back });
1916
+ if (prompt) params.set("prompt", prompt);
1917
+ return `${basePath}/sign-in?${params.toString()}`;
1918
+ };
1919
+ return {
1920
+ code,
1921
+ message: signInErrorMessage(code),
1922
+ retryPath: signIn(),
1923
+ anotherAccountPath: signIn("select_account")
1924
+ };
1925
+ }
1926
+ function escape(text) {
1927
+ return text.replace(/[&<>"']/g, (c) => ({
1928
+ "&": "&amp;",
1929
+ "<": "&lt;",
1930
+ ">": "&gt;",
1931
+ "\"": "&quot;",
1932
+ "'": "&#39;"
1933
+ })[c]);
1934
+ }
1935
+ /**
1936
+ * The proxy's own page for a sign-in error, for an app that has none of its
1937
+ * own (`synqProxy`'s `signInError`): plain HTML, no script and no style, so
1938
+ * any Content-Security-Policy the app sends allows it.
1939
+ */
1940
+ function signInErrorPage(error) {
1941
+ const html = `<!doctype html>
1942
+ <html lang="en">
1943
+ <head>
1944
+ <meta charset="utf-8">
1945
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1946
+ <meta name="robots" content="noindex">
1947
+ <title>Sign-in didn’t finish</title>
1948
+ </head>
1949
+ <body>
1950
+ <main>
1951
+ <h1>Sign-in didn’t finish</h1>
1952
+ <p>${escape(error.message)}</p>
1953
+ <p><a href="${escape(error.retryPath)}">Try again</a></p>
1954
+ <p><a href="${escape(error.anotherAccountPath)}">Use another account</a></p>
1955
+ </main>
1956
+ </body>
1957
+ </html>
1958
+ `;
1959
+ return new Response(html, {
1960
+ status: 200,
1961
+ headers: {
1962
+ "content-type": "text/html; charset=utf-8",
1963
+ "cache-control": "no-store",
1964
+ "x-content-type-options": "nosniff"
1965
+ }
1966
+ });
1967
+ }
1968
+ //#endregion
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 };