@synqauth/nextjs 0.0.0-stage → 0.0.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,1160 @@
1
+ import { SynqError, createSynqClient, isSynqError, memoryStorage } 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
+ /** The sealing key for a session secret, derived once per secret. */
8
+ function sealingKey(secret) {
9
+ if (encoder.encode(secret).length < 32) throw new TypeError(`The session secret must be at least 32 bytes`);
10
+ let key = keys.get(secret);
11
+ if (!key) {
12
+ key = (async () => {
13
+ const base = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
14
+ return crypto.subtle.deriveKey({
15
+ name: "HKDF",
16
+ hash: "SHA-256",
17
+ salt: encoder.encode("@synqauth/nextjs session"),
18
+ info: encoder.encode(VERSION)
19
+ }, base, {
20
+ name: "AES-GCM",
21
+ length: 256
22
+ }, false, ["encrypt", "decrypt"]);
23
+ })();
24
+ keys.set(secret, key);
25
+ }
26
+ return key;
27
+ }
28
+ async function sealingKeys(current, previous = []) {
29
+ const open = await Promise.all([current, ...previous].map((secret) => sealingKey(secret)));
30
+ return {
31
+ seal: open[0],
32
+ open
33
+ };
34
+ }
35
+ /** `plaintext` sealed for the cookie `name`: `v1.<iv>.<ciphertext>`, base64url. */
36
+ async function seal(key, plaintext, name) {
37
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
38
+ const sealed = await crypto.subtle.encrypt({
39
+ name: "AES-GCM",
40
+ iv,
41
+ additionalData: encoder.encode(name)
42
+ }, key, encoder.encode(plaintext));
43
+ return `${VERSION}.${base64url(iv)}.${base64url(new Uint8Array(sealed))}`;
44
+ }
45
+ /** What `seal` sealed for `name` with any of `keys`, or undefined for anything else: another cookie's, tampered, or another secret's. */
46
+ async function unseal(keys, sealed, name) {
47
+ if (!sealed) return void 0;
48
+ const [version, iv, data, ...rest] = sealed.split(".");
49
+ if (version !== VERSION || !iv || !data || rest.length > 0) return void 0;
50
+ const candidates = Array.isArray(keys) ? keys : [keys];
51
+ for (const key of candidates) try {
52
+ const opened = await crypto.subtle.decrypt({
53
+ name: "AES-GCM",
54
+ iv: fromBase64url(iv),
55
+ additionalData: encoder.encode(name)
56
+ }, key, fromBase64url(data));
57
+ return decoder.decode(opened);
58
+ } catch {}
59
+ }
60
+ /** A random id: 128 bits, base64url. */
61
+ function randomId() {
62
+ return base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)));
63
+ }
64
+ function base64url(bytes) {
65
+ let binary = "";
66
+ for (const byte of bytes) binary += String.fromCharCode(byte);
67
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
68
+ }
69
+ function fromBase64url(text) {
70
+ const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
71
+ const bytes = new Uint8Array(binary.length);
72
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
73
+ return bytes;
74
+ }
75
+ //#endregion
76
+ //#region src/config.ts
77
+ /** Milliseconds a request to Synq may take before it's given up: shorter than any lock a store holds for a refresh. */
78
+ const REQUEST_TIMEOUT_MS = 1e4;
79
+ /** Variables that say the app may run on more than one server process: serverless functions, replicas. */
80
+ const MULTI_PROCESS = [
81
+ "VERCEL",
82
+ "AWS_LAMBDA_FUNCTION_NAME",
83
+ "NETLIFY",
84
+ "K_SERVICE",
85
+ "KUBERNETES_SERVICE_HOST"
86
+ ];
87
+ /** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
88
+ function resolveConfig(options = {}, env = processEnv()) {
89
+ const issuer = required(options.issuer ?? env.SYNQ_ISSUER, "SYNQ_ISSUER").replace(/\/+$/, "");
90
+ checkUrl(issuer, "SYNQ_ISSUER");
91
+ const clientId = required(options.clientId ?? env.SYNQ_CLIENT_ID, "SYNQ_CLIENT_ID");
92
+ const clientSecret = required(options.clientSecret ?? env.SYNQ_CLIENT_SECRET, "SYNQ_CLIENT_SECRET");
93
+ const appUrl = checkUrl(required(options.appUrl ?? env.SYNQ_APP_URL, "SYNQ_APP_URL"), "SYNQ_APP_URL");
94
+ const sessionSecret = required(options.sessionSecret ?? env.SYNQ_SESSION_SECRET, "SYNQ_SESSION_SECRET");
95
+ const previousSessionSecrets = options.previousSessionSecrets ?? (env.SYNQ_SESSION_SECRET_PREVIOUS ?? "").split(",").map((secret) => secret.trim()).filter(Boolean);
96
+ for (const secret of [sessionSecret, ...previousSessionSecrets]) if (new TextEncoder().encode(secret).length < 32) throw new SynqError("invalid_options", `Each session secret must be at least 32 bytes: make one with \`openssl rand -base64 32\``);
97
+ const hint = MULTI_PROCESS.find((name) => env[name]);
98
+ 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`);
99
+ const basePath = normalizePath(options.basePath ?? "/api/auth");
100
+ const accountApi = options.account === false ? void 0 : `${issuer}/me`;
101
+ const primary = options.resource ?? accountApi;
102
+ const scopes = /* @__PURE__ */ new Set([
103
+ "openid",
104
+ "profile",
105
+ "email",
106
+ "offline_access"
107
+ ]);
108
+ if (accountApi) for (const scope of ["me:read", "me:write"]) scopes.add(scope);
109
+ for (const scope of (options.scope ?? "").split(/\s+/)) if (scope) scopes.add(scope);
110
+ const origin = appUrl.origin;
111
+ return {
112
+ issuer,
113
+ clientId,
114
+ clientSecret,
115
+ appUrl,
116
+ sessionSecret,
117
+ previousSessionSecrets,
118
+ basePath,
119
+ primary,
120
+ accountApi,
121
+ scope: [...scopes].join(" "),
122
+ redirectUri: `${origin}${basePath}/callback`,
123
+ postLogoutRedirectUri: options.postLogoutRedirectUri ?? `${origin}/`,
124
+ store: options.store,
125
+ sessionMaxAge: options.sessionMaxAge ?? 2592e3,
126
+ refreshLeewaySeconds: options.refreshLeewaySeconds ?? 30,
127
+ plugins: options.plugins ?? [],
128
+ fetch: options.fetch,
129
+ secure: appUrl.protocol === "https:"
130
+ };
131
+ }
132
+ function processEnv() {
133
+ return globalThis.process?.env ?? {};
134
+ }
135
+ function required(value, name) {
136
+ if (!value) throw new SynqError("invalid_options", `${name} is missing`);
137
+ return value;
138
+ }
139
+ /** An https URL, or http on this machine only. */
140
+ function checkUrl(value, name) {
141
+ let url;
142
+ try {
143
+ url = new URL(value);
144
+ } catch {
145
+ throw new SynqError("invalid_options", `${name} isn't a URL`);
146
+ }
147
+ const loopback = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
148
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new SynqError("invalid_options", `${name} must be https (http only on this machine)`);
149
+ return url;
150
+ }
151
+ /** The route mount's path, `/api/auth` style; throws `invalid_options` for anything else. */
152
+ function normalizePath(path) {
153
+ const trimmed = `/${path.replace(/^\/+|\/+$/g, "")}`;
154
+ if (!/^\/[A-Za-z0-9._~\-/]*$/.test(trimmed) || trimmed.includes("//")) throw new SynqError("invalid_options", "basePath must be a plain path, such as /api/auth");
155
+ return trimmed;
156
+ }
157
+ //#endregion
158
+ //#region src/cookies.ts
159
+ /** Characters per chunk, under the 4096 bytes a browser keeps for one cookie with its attributes. */
160
+ const CHUNK_SIZE = 3800;
161
+ /** A cookie's full name: `__Host-<base>` over https, `<base>` on a plain-http stack on this machine. */
162
+ function cookieName(base, secure) {
163
+ return secure ? `__Host-${base}` : base;
164
+ }
165
+ /** A value kept under `name`, joined from its chunks; undefined when there is none. */
166
+ function readChunked(jar, name) {
167
+ const whole = jar.get(name);
168
+ if (whole !== void 0) return whole;
169
+ let value = "";
170
+ for (let i = 0;; i++) {
171
+ const chunk = jar.get(`${name}.${i}`);
172
+ if (chunk === void 0) return i === 0 ? void 0 : value;
173
+ value += chunk;
174
+ }
175
+ }
176
+ /** Keeps `value` under `name`, in chunks when it needs them, and deletes chunks a longer value left behind. */
177
+ function writeChunked(jar, name, value, options) {
178
+ const existing = chunkNames(jar, name);
179
+ if (value.length <= 3800) {
180
+ jar.set(name, value, options);
181
+ for (const old of existing) if (old !== name) jar.delete(old, options);
182
+ return;
183
+ }
184
+ const written = /* @__PURE__ */ new Set();
185
+ for (let i = 0; i * CHUNK_SIZE < value.length; i++) {
186
+ const chunk = `${name}.${i}`;
187
+ jar.set(chunk, value.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE), options);
188
+ written.add(chunk);
189
+ }
190
+ for (const old of existing) if (!written.has(old)) jar.delete(old, options);
191
+ }
192
+ /** Deletes `name` and every chunk of it. */
193
+ function deleteChunked(jar, name, options) {
194
+ for (const old of chunkNames(jar, name)) jar.delete(old, options);
195
+ }
196
+ function chunkNames(jar, name) {
197
+ const pattern = new RegExp(`^${escapeRegExp(name)}(\\.\\d+)?$`);
198
+ return [...jar.names()].filter((candidate) => pattern.test(candidate));
199
+ }
200
+ /** The Set-Cookie header for a cookie: `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` over https. */
201
+ function setCookieHeader(name, value, options) {
202
+ const parts = [
203
+ `${name}=${value}`,
204
+ "Path=/",
205
+ "HttpOnly",
206
+ "SameSite=Lax"
207
+ ];
208
+ if (options.maxAge !== void 0) parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
209
+ if (options.secure) parts.push("Secure");
210
+ return parts.join("; ");
211
+ }
212
+ /** The cookies a `Cookie` header carries. */
213
+ function parseCookieHeader(header) {
214
+ const cookies = /* @__PURE__ */ new Map();
215
+ for (const pair of (header ?? "").split(";")) {
216
+ const at = pair.indexOf("=");
217
+ if (at <= 0) continue;
218
+ const name = pair.slice(0, at).trim();
219
+ if (!cookies.has(name)) cookies.set(name, pair.slice(at + 1).trim());
220
+ }
221
+ return cookies;
222
+ }
223
+ /** A jar over a request's cookies that collects the Set-Cookie headers its response sends. */
224
+ function requestJar(request) {
225
+ const cookies = parseCookieHeader(request.headers.get("cookie"));
226
+ const out = /* @__PURE__ */ new Map();
227
+ return {
228
+ get: (name) => cookies.get(name),
229
+ names: () => cookies.keys(),
230
+ set(name, value, options) {
231
+ cookies.set(name, value);
232
+ out.set(name, setCookieHeader(name, value, options));
233
+ },
234
+ delete(name, options) {
235
+ cookies.delete(name);
236
+ out.set(name, setCookieHeader(name, "", {
237
+ ...options,
238
+ maxAge: 0
239
+ }));
240
+ },
241
+ headers: () => [...out.values()],
242
+ /** The `Cookie` header the request carries now, changes made: for what runs after Next's proxy. */
243
+ cookieHeader: () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")
244
+ };
245
+ }
246
+ function escapeRegExp(text) {
247
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
248
+ }
249
+ //#endregion
250
+ //#region src/plugins.ts
251
+ const PLUGIN_ID = /^[a-z][a-z0-9-]{0,39}$/;
252
+ const ROUTE_KEY = /^(GET|POST|PATCH|DELETE) \/[A-Za-z0-9._~\-/]*$/;
253
+ /** Declares a server plugin, checking its id and routes once, when the module loads. */
254
+ function defineSynqPlugin(plugin) {
255
+ 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}`);
256
+ 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}`);
257
+ return plugin;
258
+ }
259
+ /** The route mount's own paths, which no plugin can take. */
260
+ const RESERVED = /* @__PURE__ */ new Set([
261
+ "sign-in",
262
+ "callback",
263
+ "sign-out",
264
+ "session",
265
+ "account",
266
+ "link"
267
+ ]);
268
+ //#endregion
269
+ //#region src/session-name.ts
270
+ const SESSION_COOKIE = "synq.session";
271
+ //#endregion
272
+ //#region src/store.ts
273
+ /**
274
+ * A store in this process's memory: for tests and a single server process,
275
+ * where the default cookie does as well.
276
+ */
277
+ function memorySessionStore() {
278
+ const sessions = /* @__PURE__ */ new Map();
279
+ const locks = /* @__PURE__ */ new Map();
280
+ return {
281
+ get(id) {
282
+ const entry = sessions.get(id);
283
+ if (!entry || entry.expires <= Date.now()) {
284
+ sessions.delete(id);
285
+ return Promise.resolve(null);
286
+ }
287
+ return Promise.resolve({ ...entry.values });
288
+ },
289
+ set(id, values, maxAgeSeconds) {
290
+ sessions.set(id, {
291
+ values: { ...values },
292
+ expires: Date.now() + maxAgeSeconds * 1e3
293
+ });
294
+ return Promise.resolve();
295
+ },
296
+ delete(id) {
297
+ sessions.delete(id);
298
+ return Promise.resolve();
299
+ },
300
+ lock: (id, fn) => chain(locks, id, fn)
301
+ };
302
+ }
303
+ /**
304
+ * Sessions in a key-value store every server process shares, such as Redis
305
+ * or Valkey: the store an app on several processes (replicas, serverless
306
+ * functions) needs. Its lock is `SET … NX PX` with a token, polled until
307
+ * free, and released only by its holder; it expires on its own, so a dead
308
+ * process never holds a session for ever.
309
+ *
310
+ * ```ts
311
+ * keyValueSessionStore({
312
+ * get: (key) => redis.get(key),
313
+ * set: async (key, value, { ttlMs, onlyIfAbsent }) =>
314
+ * (await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
315
+ * delete: async (key) => void (await redis.del(key)),
316
+ * })
317
+ * ```
318
+ */
319
+ function keyValueSessionStore(kv, options = {}) {
320
+ const prefix = options.prefix ?? "synq:";
321
+ const lockTtlMs = options.lockTtlMs ?? 3e4;
322
+ const lockWaitMs = options.lockWaitMs ?? 35e3;
323
+ 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`);
324
+ const sessionKey = (id) => `${prefix}session:${id}`;
325
+ const lockKey = (id) => `${prefix}lock:${id}`;
326
+ return {
327
+ async get(id) {
328
+ const raw = await kv.get(sessionKey(id));
329
+ if (!raw) return null;
330
+ try {
331
+ return JSON.parse(raw);
332
+ } catch {
333
+ return null;
334
+ }
335
+ },
336
+ async set(id, values, maxAgeSeconds) {
337
+ await kv.set(sessionKey(id), JSON.stringify(values), { ttlMs: maxAgeSeconds * 1e3 });
338
+ },
339
+ async delete(id) {
340
+ await kv.delete(sessionKey(id));
341
+ },
342
+ async lock(id, fn) {
343
+ const key = lockKey(id);
344
+ const token = crypto.randomUUID();
345
+ const deadline = Date.now() + lockWaitMs;
346
+ let wait = 25;
347
+ while (!await kv.set(key, token, {
348
+ ttlMs: lockTtlMs,
349
+ onlyIfAbsent: true
350
+ })) {
351
+ if (Date.now() >= deadline) throw new SynqError("lock_timeout", "Another request held this session's lock too long");
352
+ await new Promise((resolve) => setTimeout(resolve, wait));
353
+ wait = Math.min(wait * 2, 250);
354
+ }
355
+ try {
356
+ return await fn();
357
+ } finally {
358
+ if (kv.deleteIfEquals) await kv.deleteIfEquals(key, token);
359
+ else if (await kv.get(key) === token) await kv.delete(key);
360
+ }
361
+ }
362
+ };
363
+ }
364
+ /** Runs `fn` after every earlier holder of `name` in `locks`, failed or not. */
365
+ function chain(locks, name, fn) {
366
+ const run = (locks.get(name) ?? Promise.resolve()).then(fn, fn);
367
+ const settled = run.then(() => void 0, () => void 0);
368
+ locks.set(name, settled);
369
+ settled.then(() => {
370
+ if (locks.get(name) === settled) locks.delete(name);
371
+ });
372
+ return run;
373
+ }
374
+ //#endregion
375
+ //#region src/session.ts
376
+ /**
377
+ * @synqauth/js's keys in its store (client.ts), which the adapter maps onto
378
+ * a session's values: the session per API, and the refresh token.
379
+ */
380
+ const JS_SESSION_KEY = "synq.session";
381
+ const JS_REFRESH_KEY = "synq.refresh";
382
+ const REFRESH = "refresh";
383
+ const SHARED = Symbol.for("@synqauth/nextjs sessions");
384
+ const { newest, locks } = globalThis[SHARED] ??= {
385
+ newest: /* @__PURE__ */ new Map(),
386
+ locks: /* @__PURE__ */ new Map()
387
+ };
388
+ const OVERLAY_MAX = 1e4;
389
+ const OVERLAY_TTL_MS = 18e5;
390
+ var SessionHandle = class {
391
+ id;
392
+ created;
393
+ #backend;
394
+ /** Whether the cookie must be written: a new session, or values newer than the cookie's. */
395
+ #cookieRev;
396
+ #fresh;
397
+ constructor(id, created, backend, cookieRev, fresh) {
398
+ this.id = id;
399
+ this.created = created;
400
+ this.#backend = backend;
401
+ this.#cookieRev = cookieRev;
402
+ this.#fresh = fresh;
403
+ }
404
+ read() {
405
+ return this.#backend.read();
406
+ }
407
+ /** `@synqauth/js`'s store for this session's token for `resource`; `primary` is the API whose session holds the ID token. */
408
+ storage(resource, primary) {
409
+ const own = tokenKey(resource);
410
+ const main = tokenKey(primary);
411
+ const backend = this.#backend;
412
+ return {
413
+ holdsRefreshTokens: true,
414
+ async get(key) {
415
+ const values = await backend.read();
416
+ if (key === JS_REFRESH_KEY) return values[REFRESH];
417
+ if (key !== JS_SESSION_KEY) return void 0;
418
+ const mine = values[own];
419
+ const first = values[main];
420
+ if (own === main) return mine;
421
+ if (!first) return mine;
422
+ const { idToken, claims } = JSON.parse(first);
423
+ if (mine) return JSON.stringify({
424
+ ...JSON.parse(mine),
425
+ idToken,
426
+ claims
427
+ });
428
+ return JSON.stringify({
429
+ accessToken: "",
430
+ tokenType: "Bearer",
431
+ expiresAt: 0,
432
+ idToken,
433
+ claims
434
+ });
435
+ },
436
+ async set(key, value) {
437
+ if (key === JS_REFRESH_KEY) return backend.write((values) => void (values[REFRESH] = value));
438
+ if (key !== JS_SESSION_KEY) return;
439
+ let kept = value;
440
+ if (own !== main) {
441
+ const rest = JSON.parse(value);
442
+ delete rest.idToken;
443
+ delete rest.claims;
444
+ kept = JSON.stringify(rest);
445
+ }
446
+ await backend.write((values) => void (values[own] = kept));
447
+ },
448
+ async remove(key) {
449
+ if (key === JS_REFRESH_KEY) return backend.write((values) => void delete values[REFRESH]);
450
+ if (key !== JS_SESSION_KEY) return;
451
+ await backend.write((values) => {
452
+ if (own === main) for (const name of Object.keys(values)) delete values[name];
453
+ else delete values[own];
454
+ });
455
+ },
456
+ lock: (_name, fn) => backend.lock(fn)
457
+ };
458
+ }
459
+ /** Writes the session's cookie when it's new or this process holds newer values than it. */
460
+ async commit(jar, keys, secure, maxAge, store) {
461
+ const name = cookieName(SESSION_COOKIE, secure);
462
+ const remaining = Math.max(0, this.created + maxAge - now$1());
463
+ if (store) {
464
+ if (!this.#fresh) return;
465
+ const sealed = {
466
+ v: 1,
467
+ id: this.id,
468
+ created: this.created
469
+ };
470
+ writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
471
+ secure,
472
+ maxAge: remaining
473
+ });
474
+ this.#fresh = false;
475
+ return;
476
+ }
477
+ const current = newest.get(this.id);
478
+ const rev = current?.rev ?? 0;
479
+ if (!this.#fresh && rev <= this.#cookieRev) return;
480
+ const sealed = {
481
+ v: 1,
482
+ id: this.id,
483
+ created: this.created,
484
+ rev,
485
+ values: current?.values ?? {}
486
+ };
487
+ writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
488
+ secure,
489
+ maxAge: remaining
490
+ });
491
+ this.#cookieRev = rev;
492
+ this.#fresh = false;
493
+ }
494
+ };
495
+ /** The session the request's cookie names, or null: none, unreadable, past its age, or gone from the store. */
496
+ async function loadSession(jar, keys, secure, maxAge, store) {
497
+ const name = cookieName(SESSION_COOKIE, secure);
498
+ const opened = await unseal(keys.open, readChunked(jar, name), name);
499
+ if (!opened) return null;
500
+ let sealed;
501
+ try {
502
+ sealed = JSON.parse(opened);
503
+ } catch {
504
+ return null;
505
+ }
506
+ if (sealed.v !== 1 || typeof sealed.id !== "string" || sealed.created + maxAge <= now$1()) return null;
507
+ if (store) {
508
+ if (!await store.get(sealed.id)) return null;
509
+ return new SessionHandle(sealed.id, sealed.created, storeBackend(store, sealed.id, sealed.created, maxAge), 0, false);
510
+ }
511
+ const rev = sealed.rev ?? 0;
512
+ remember(sealed.id, rev, sealed.values ?? {});
513
+ return new SessionHandle(sealed.id, sealed.created, cookieBackend(sealed.id), rev, false);
514
+ }
515
+ /** A new, empty session: what a sign-in fills. */
516
+ function newSession(maxAge, store) {
517
+ const id = randomId();
518
+ const created = now$1();
519
+ if (store) return new SessionHandle(id, created, storeBackend(store, id, created, maxAge), 0, true);
520
+ remember(id, 0, {});
521
+ return new SessionHandle(id, created, cookieBackend(id), -1, true);
522
+ }
523
+ /** Ends a session: its cookie, its stored values, and what this process remembers of it. */
524
+ async function endSession(jar, handle, secure, store) {
525
+ if (handle) {
526
+ newest.delete(handle.id);
527
+ if (store) await store.delete(handle.id);
528
+ }
529
+ deleteChunked(jar, cookieName(SESSION_COOKIE, secure), { secure });
530
+ }
531
+ /** The key a session keeps `resource`'s token under. */
532
+ function tokenKey(resource) {
533
+ return `token:${resource ?? ""}`;
534
+ }
535
+ function cookieBackend(id) {
536
+ return {
537
+ read() {
538
+ const entry = newest.get(id);
539
+ return Promise.resolve({ ...entry?.values ?? {} });
540
+ },
541
+ write(mutate) {
542
+ const entry = newest.get(id);
543
+ const values = { ...entry?.values ?? {} };
544
+ mutate(values);
545
+ remember(id, (entry?.rev ?? 0) + 1, values);
546
+ return Promise.resolve();
547
+ },
548
+ lock: (fn) => chain(locks, id, fn)
549
+ };
550
+ }
551
+ function storeBackend(store, id, created, maxAge) {
552
+ return {
553
+ async read() {
554
+ return await store.get(id) ?? {};
555
+ },
556
+ async write(mutate) {
557
+ const values = await store.get(id) ?? {};
558
+ mutate(values);
559
+ await store.set(id, values, Math.max(1, created + maxAge - now$1()));
560
+ },
561
+ lock: (fn) => store.lock(id, fn)
562
+ };
563
+ }
564
+ /** Keeps the newer of what's remembered and `rev`'s values. */
565
+ function remember(id, rev, values) {
566
+ const entry = newest.get(id);
567
+ const time = Date.now();
568
+ if (entry && entry.rev >= rev) {
569
+ entry.seen = time;
570
+ return;
571
+ }
572
+ newest.delete(id);
573
+ newest.set(id, {
574
+ rev,
575
+ values,
576
+ seen: time
577
+ });
578
+ if (newest.size > OVERLAY_MAX) for (const [old, kept] of newest) {
579
+ if (newest.size <= OVERLAY_MAX && time - kept.seen < OVERLAY_TTL_MS) break;
580
+ newest.delete(old);
581
+ }
582
+ }
583
+ function now$1() {
584
+ return Math.floor(Date.now() / 1e3);
585
+ }
586
+ //#endregion
587
+ //#region src/core.ts
588
+ const PROMPTS = /* @__PURE__ */ new Set([
589
+ "login",
590
+ "none",
591
+ "consent",
592
+ "select_account"
593
+ ]);
594
+ const INTENTS = /* @__PURE__ */ new Set(["add-passkey"]);
595
+ /** A sign-in method's name, as `synq_method` and the account API's link requests take it. */
596
+ const METHOD = /^[a-z0-9][a-z0-9_-]{0,63}$/;
597
+ const TRANSACTION_SECONDS = 3600;
598
+ const TRANSACTION_PREFIX = "synq.tx.";
599
+ const ACCOUNT_BODY_LIMIT = 65536;
600
+ const LINK_BODY_LIMIT = 4096;
601
+ /** `synq_siws` as @synqauth/solana's `encodeSiws` writes it: base64url, well under the body's limit. */
602
+ const SIWS = /^[A-Za-z0-9_-]{1,3072}$/;
603
+ /** Claims of the ID token that say nothing about the person. */
604
+ const PROTOCOL_CLAIMS = /* @__PURE__ */ new Set([
605
+ "iss",
606
+ "aud",
607
+ "azp",
608
+ "exp",
609
+ "iat",
610
+ "nbf",
611
+ "nonce",
612
+ "at_hash",
613
+ "c_hash",
614
+ "sid",
615
+ "jti"
616
+ ]);
617
+ /**
618
+ * The framework-neutral core `synqAuth()` runs on, over any cookie jar: for
619
+ * a server that answers the same routes without Next. Most apps use
620
+ * `synqAuth()` instead.
621
+ */
622
+ function createSynqCore(config) {
623
+ const keysFor = () => sealingKeys(config.sessionSecret, config.previousSessionSecrets);
624
+ /** Every request to Synq: never cached, and given up after REQUEST_TIMEOUT_MS. */
625
+ const upstream = (input, init = {}) => {
626
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
627
+ return (config.fetch ?? fetch)(input, {
628
+ ...init,
629
+ cache: "no-store",
630
+ signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout
631
+ });
632
+ };
633
+ function client(storage, resource, transactions) {
634
+ return createSynqClient({
635
+ issuer: config.issuer,
636
+ clientId: config.clientId,
637
+ clientSecret: config.clientSecret,
638
+ redirectUri: config.redirectUri,
639
+ postLogoutRedirectUri: config.postLogoutRedirectUri,
640
+ scope: config.scope,
641
+ resource,
642
+ storage,
643
+ transactionStorage: transactions ?? memoryStorage(),
644
+ fetch: upstream,
645
+ refreshLeewaySeconds: config.refreshLeewaySeconds
646
+ });
647
+ }
648
+ async function load(jar) {
649
+ return loadSession(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
650
+ }
651
+ async function commit(jar, handle) {
652
+ await handle.commit(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
653
+ }
654
+ /** Who signed in (the ID token's `sub`), and in which session at the issuer (its `sid`, when Synq sent one). */
655
+ async function issuerSessionOf(handle) {
656
+ const raw = (await handle.read())[tokenKey(config.primary)];
657
+ if (!raw) return void 0;
658
+ const { claims } = JSON.parse(raw);
659
+ if (typeof claims?.sub !== "string") return void 0;
660
+ return {
661
+ sub: claims.sub,
662
+ sid: typeof claims.sid === "string" ? claims.sid : void 0
663
+ };
664
+ }
665
+ /** The person the session's main token was issued with, while the session can still give a token. */
666
+ async function userOf(handle) {
667
+ if (!handle) return {
668
+ user: null,
669
+ expiresAt: null
670
+ };
671
+ const values = await handle.read();
672
+ const raw = values[tokenKey(config.primary)];
673
+ if (!raw) return {
674
+ user: null,
675
+ expiresAt: null
676
+ };
677
+ const stored = JSON.parse(raw);
678
+ const expiresAt = stored.expiresAt ?? null;
679
+ if (!values.refresh && expiresAt !== null && expiresAt <= now()) return {
680
+ user: null,
681
+ expiresAt: null
682
+ };
683
+ if (!stored.claims || typeof stored.claims.sub !== "string") return {
684
+ user: null,
685
+ expiresAt
686
+ };
687
+ const user = {};
688
+ for (const [name, value] of Object.entries(stored.claims)) if (!PROTOCOL_CLAIMS.has(name)) user[name] = value;
689
+ return {
690
+ user,
691
+ expiresAt
692
+ };
693
+ }
694
+ async function getAccessToken(jar, options = {}) {
695
+ const resource = options.resource ?? config.primary;
696
+ 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`);
697
+ const handle = await load(jar);
698
+ if (!handle) throw new SynqError("not_signed_in", "Nobody is signed in");
699
+ if (!config.store && jar.writable?.() === false) {
700
+ const raw = (await handle.read())[tokenKey(resource)];
701
+ const stored = raw ? JSON.parse(raw) : {};
702
+ if (stored.accessToken && (stored.expiresAt === void 0 || stored.expiresAt > now())) return stored.accessToken;
703
+ 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");
704
+ }
705
+ try {
706
+ const token = await client(handle.storage(resource, config.primary), resource).getAccessToken({ minValiditySeconds: options.minValiditySeconds });
707
+ await commit(jar, handle);
708
+ return token;
709
+ } catch (error) {
710
+ if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) await endSession(jar, handle, config.secure, config.store);
711
+ throw error;
712
+ }
713
+ }
714
+ /** A store for one sign-in's transaction: a sealed cookie named after its state, with where to go afterwards. */
715
+ async function transactions(jar, returnTo) {
716
+ const keys = await keysFor();
717
+ const options = {
718
+ secure: config.secure,
719
+ maxAge: TRANSACTION_SECONDS
720
+ };
721
+ const found = {};
722
+ return {
723
+ storage: {
724
+ async get(name) {
725
+ const cookie = cookieName(name, config.secure);
726
+ const opened = await unseal(keys.open, jar.get(cookie), cookie);
727
+ if (!opened) return void 0;
728
+ const { v, r } = JSON.parse(opened);
729
+ found.returnTo = r;
730
+ return v;
731
+ },
732
+ async set(name, value) {
733
+ await dropOldTransactions(jar, keys.open);
734
+ const cookie = cookieName(name, config.secure);
735
+ const sealed = JSON.stringify({
736
+ v: value,
737
+ r: returnTo,
738
+ t: Date.now()
739
+ });
740
+ jar.set(cookie, await seal(keys.seal, sealed, cookie), options);
741
+ },
742
+ remove(name) {
743
+ jar.delete(cookieName(name, config.secure), options);
744
+ }
745
+ },
746
+ found
747
+ };
748
+ }
749
+ /** Keeps a browser's sign-ins under way to MAX_TRANSACTIONS, the newest, before it starts another. */
750
+ async function dropOldTransactions(jar, keys) {
751
+ const prefix = cookieName(TRANSACTION_PREFIX, config.secure);
752
+ const started = [];
753
+ for (const name of [...jar.names()]) {
754
+ if (!name.startsWith(prefix)) continue;
755
+ const opened = await unseal(keys, jar.get(name), name);
756
+ let at;
757
+ try {
758
+ at = opened ? JSON.parse(opened).t ?? 0 : 0;
759
+ } catch {
760
+ at = 0;
761
+ }
762
+ started.push({
763
+ name,
764
+ at,
765
+ order: started.length
766
+ });
767
+ }
768
+ started.sort((a, b) => b.at - a.at || b.order - a.order);
769
+ for (const { name } of started.slice(4)) jar.delete(name, { secure: config.secure });
770
+ }
771
+ async function signIn(request, jar) {
772
+ const params = new URL(request.url).searchParams;
773
+ const returnTo = safeReturnTo(params.get("returnTo"), config.appUrl);
774
+ const prompt = params.get("prompt") ?? void 0;
775
+ const maxAge = params.get("max_age");
776
+ const intent = params.get("intent") ?? void 0;
777
+ if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent or select_account");
778
+ if (maxAge !== null && !/^\d{1,9}$/.test(maxAge)) return problem(400, "invalid_request", "max_age is a number of seconds");
779
+ if (intent && !INTENTS.has(intent)) return problem(400, "invalid_request", "intent is add-passkey (POST link adds a way to sign in)");
780
+ const tx = await transactions(jar, returnTo);
781
+ return redirect((await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
782
+ method: params.get("method") ?? void 0,
783
+ loginHint: params.get("login_hint") ?? void 0,
784
+ prompt: prompt ?? (intent ? "login" : void 0),
785
+ maxAge: maxAge === null ? void 0 : Number(maxAge),
786
+ additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
787
+ params: intent ? { synq_intent: intent } : void 0
788
+ })).href, 302);
789
+ }
790
+ async function callback(request, jar) {
791
+ const tx = await transactions(jar);
792
+ const previous = await load(jar);
793
+ const handle = newSession(config.sessionMaxAge, config.store);
794
+ try {
795
+ await client(handle.storage(config.primary, config.primary), config.primary, tx.storage).handleCallback(request.url);
796
+ } catch (error) {
797
+ const code = isSynqError(error) ? error.code : "server_error";
798
+ return redirect(withError(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), code), 303);
799
+ }
800
+ if (previous) {
801
+ const [before, after] = await Promise.all([issuerSessionOf(previous), issuerSessionOf(handle)]);
802
+ if (!(before !== void 0 && after !== void 0 && before.sub === after.sub && (before.sid === void 0 || after.sid === void 0 || before.sid === after.sid))) try {
803
+ await client(previous.storage(config.primary, config.primary), config.primary).signOut();
804
+ } catch {}
805
+ await endSession(jar, previous, config.secure, config.store);
806
+ }
807
+ await commit(jar, handle);
808
+ return redirect(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), 303);
809
+ }
810
+ async function signOut(request, jar) {
811
+ const handle = await load(jar);
812
+ let url = null;
813
+ if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut();
814
+ await endSession(jar, handle, config.secure, config.store);
815
+ const target = url?.href ?? config.postLogoutRedirectUri;
816
+ if ((request.headers.get("accept") ?? "").includes("application/json")) return json({ redirect: target });
817
+ return redirect(target, 303);
818
+ }
819
+ /**
820
+ * Adding a way to sign in (AERO-1397): the account API makes a link request
821
+ * for the person with the session's token, and its ticket goes to Synq in
822
+ * a sign-in like any other, which asks them to sign in again first. A POST
823
+ * from the app's own pages, answered `{ redirect }` for the browser to
824
+ * follow, so another site can't start one.
825
+ */
826
+ async function link(request, jar) {
827
+ if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
828
+ const length = Number(request.headers.get("content-length") ?? 0);
829
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
830
+ if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
831
+ let input;
832
+ try {
833
+ const body = await request.text();
834
+ if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
835
+ input = JSON.parse(body);
836
+ } catch {
837
+ return problem(400, "invalid_request", "Send { method, returnTo } as JSON");
838
+ }
839
+ if (typeof input?.method !== "string" || !METHOD.test(input.method)) return problem(400, "invalid_request", "method names the way to sign in to add");
840
+ const returnTo = safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl);
841
+ let token;
842
+ try {
843
+ token = await getAccessToken(jar, { resource: config.accountApi });
844
+ } catch (error) {
845
+ if (isSynqError(error) && [
846
+ "not_signed_in",
847
+ "invalid_grant",
848
+ "no_refresh_token"
849
+ ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
850
+ throw error;
851
+ }
852
+ const answer = await upstream(`${config.accountApi}/v1/link-requests`, {
853
+ method: "POST",
854
+ headers: {
855
+ authorization: `Bearer ${token}`,
856
+ accept: "application/json",
857
+ "content-type": "application/json"
858
+ },
859
+ body: JSON.stringify({ method: input.method })
860
+ });
861
+ if (!answer.ok) return new Response(answer.body, {
862
+ status: answer.status,
863
+ headers: { "content-type": answer.headers.get("content-type") ?? "application/problem+json" }
864
+ });
865
+ const ticket = (await answer.json()).authorization_params?.synq_link;
866
+ if (typeof ticket !== "string") return problem(502, "invalid_response", "Synq answered without a link ticket");
867
+ const tx = await transactions(jar, returnTo);
868
+ return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
869
+ prompt: "login",
870
+ additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
871
+ params: {
872
+ synq_intent: "link",
873
+ synq_link: ticket
874
+ }
875
+ })).href });
876
+ }
877
+ /**
878
+ * Signing in with a wallet from the app's own page (AERO-1423, over the
879
+ * issuer's headless wallet sign-in, AERO-1410): `{ siws, returnTo }`, the
880
+ * `synq_siws` @synqauth/solana's `encodeSiws` makes of what the wallet
881
+ * signed. It goes to Synq in a pushed authorization request with
882
+ * `prompt=login`, sent from this server with the app's credentials, so the
883
+ * signature never lands in a URL; answered `{ redirect }` for the browser
884
+ * to follow. A POST from the app's own pages.
885
+ */
886
+ async function walletSignIn(request, jar) {
887
+ const length = Number(request.headers.get("content-length") ?? 0);
888
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
889
+ if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
890
+ let input;
891
+ try {
892
+ const body = await request.text();
893
+ if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
894
+ input = JSON.parse(body);
895
+ } catch {
896
+ return problem(400, "invalid_request", "Send { siws, returnTo } as JSON");
897
+ }
898
+ if (typeof input?.siws !== "string" || !SIWS.test(input.siws)) return problem(400, "invalid_request", "siws is what the wallet signed, as encodeSiws makes it");
899
+ const tx = await transactions(jar, safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl));
900
+ return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
901
+ prompt: "login",
902
+ pushed: true,
903
+ additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
904
+ params: { synq_siws: input.siws }
905
+ })).href });
906
+ }
907
+ async function account(request, jar, path) {
908
+ if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
909
+ if (!/^v1(\/[A-Za-z0-9._~-]+)+$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) return problem(404, "not_found", "No such account API route");
910
+ const length = Number(request.headers.get("content-length") ?? 0);
911
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
912
+ if (!Number.isFinite(length) || length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
913
+ let token;
914
+ try {
915
+ token = await getAccessToken(jar, { resource: config.accountApi });
916
+ } catch (error) {
917
+ if (isSynqError(error) && [
918
+ "not_signed_in",
919
+ "invalid_grant",
920
+ "no_refresh_token"
921
+ ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
922
+ throw error;
923
+ }
924
+ const headers = new Headers({
925
+ authorization: `Bearer ${token}`,
926
+ accept: "application/json"
927
+ });
928
+ for (const name of ["content-type", "idempotency-key"]) {
929
+ const value = request.headers.get(name);
930
+ if (value) headers.set(name, value);
931
+ }
932
+ let body;
933
+ if (request.method !== "GET" && request.method !== "HEAD") {
934
+ body = await request.text();
935
+ if (new TextEncoder().encode(body).length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
936
+ }
937
+ const search = new URL(request.url).search;
938
+ const answer = await upstream(`${config.accountApi}/${path}${search}`, {
939
+ method: request.method,
940
+ headers,
941
+ body,
942
+ redirect: "manual"
943
+ });
944
+ const out = new Headers({ "cache-control": "no-store" });
945
+ const type = answer.headers.get("content-type");
946
+ if (type) out.set("content-type", type);
947
+ return new Response(answer.status === 204 ? null : await answer.text(), {
948
+ status: answer.status,
949
+ headers: out
950
+ });
951
+ }
952
+ async function plugin(request, jar, id, rest) {
953
+ const route = config.plugins.find((candidate) => candidate.id === id)?.routes?.[`${request.method} /${rest}`];
954
+ if (!route) return void 0;
955
+ return route({
956
+ user: async () => (await userOf(await load(jar))).user,
957
+ getAccessToken: (options) => getAccessToken(jar, options),
958
+ issuer: config.issuer,
959
+ clientId: config.clientId,
960
+ basePath: config.basePath
961
+ }, request);
962
+ }
963
+ async function handle(request) {
964
+ const url = new URL(request.url);
965
+ const prefix = `${config.basePath}/`;
966
+ if (!url.pathname.startsWith(prefix)) return problem(404, "not_found", "Not under the route mount");
967
+ const rest = url.pathname.slice(prefix.length);
968
+ const [first = "", ...tail] = rest.split("/");
969
+ const jar = requestJar(request);
970
+ if (!["GET", "HEAD"].includes(request.method) && !sameOrigin(request, config.appUrl)) return problem(403, "forbidden", "A change must come from the app's own pages");
971
+ let response;
972
+ try {
973
+ if (rest === "sign-in" && request.method === "GET") response = await signIn(request, jar);
974
+ else if (rest === "sign-in" && request.method === "POST") response = await walletSignIn(request, jar);
975
+ else if (rest === "callback" && request.method === "GET") response = await callback(request, jar);
976
+ else if (rest === "sign-out" && request.method === "POST") response = await signOut(request, jar);
977
+ else if (rest === "link" && request.method === "POST") response = await link(request, jar);
978
+ else if (rest === "session" && request.method === "GET") response = json(await userOf(await load(jar)));
979
+ else if (first === "account") response = await account(request, jar, tail.join("/"));
980
+ else if (!RESERVED.has(first)) response = await plugin(request, jar, first, tail.join("/"));
981
+ } catch (error) {
982
+ if (!isSynqError(error)) throw error;
983
+ response = problem(error.status && error.status >= 400 ? 502 : 500, error.code, error.message);
984
+ }
985
+ response ??= problem(404, "not_found", "No such route under the mount");
986
+ const headers = new Headers(response.headers);
987
+ for (const cookie of jar.headers()) headers.append("set-cookie", cookie);
988
+ if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
989
+ headers.set("x-content-type-options", "nosniff");
990
+ return new Response(response.body, {
991
+ status: response.status,
992
+ headers
993
+ });
994
+ }
995
+ async function refreshIfDue(jar) {
996
+ const handle = await load(jar);
997
+ const { user, expiresAt } = await userOf(handle);
998
+ if (!handle || !user) return false;
999
+ if (config.store || expiresAt === null || expiresAt - config.refreshLeewaySeconds > now()) return true;
1000
+ try {
1001
+ await getAccessToken(jar);
1002
+ return true;
1003
+ } catch (error) {
1004
+ if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) return false;
1005
+ return expiresAt > now();
1006
+ }
1007
+ }
1008
+ return {
1009
+ handle,
1010
+ user: async (jar) => (await userOf(await load(jar))).user,
1011
+ session: async (jar) => userOf(await load(jar)),
1012
+ getAccessToken,
1013
+ refreshIfDue,
1014
+ config
1015
+ };
1016
+ }
1017
+ /**
1018
+ * Where to send the browser back to on the app, as an absolute URL, never
1019
+ * another site: the app's root for anything else. It checks where the value
1020
+ * resolves to, not how it's spelled, since dot segments and encodings
1021
+ * (`/.//evil.example`, `/%2e//evil.example`) collapse into another host.
1022
+ */
1023
+ function safeReturnTo(value, appUrl) {
1024
+ const home = new URL("/", appUrl).href;
1025
+ if (!value || value.includes("\\")) return home;
1026
+ try {
1027
+ const url = new URL(value, appUrl);
1028
+ return url.origin === appUrl.origin && !url.pathname.startsWith("//") ? url.href : home;
1029
+ } catch {
1030
+ return home;
1031
+ }
1032
+ }
1033
+ /** A request from the app's own pages: its `Origin`, or else the browser's `Sec-Fetch-Site`, says so. */
1034
+ function sameOrigin(request, appUrl) {
1035
+ const origin = request.headers.get("origin");
1036
+ if (origin) return origin === appUrl.origin;
1037
+ return request.headers.get("sec-fetch-site") === "same-origin";
1038
+ }
1039
+ function withError(target, code) {
1040
+ const url = new URL(target);
1041
+ url.searchParams.set("synq_error", code);
1042
+ return url.href;
1043
+ }
1044
+ function redirect(location, status) {
1045
+ return new Response(null, {
1046
+ status,
1047
+ headers: { location }
1048
+ });
1049
+ }
1050
+ function json(body, status = 200) {
1051
+ return Response.json(body, {
1052
+ status,
1053
+ headers: { "cache-control": "no-store" }
1054
+ });
1055
+ }
1056
+ function problem(status, code, detail) {
1057
+ return Response.json({
1058
+ type: "about:blank",
1059
+ status,
1060
+ code,
1061
+ detail
1062
+ }, {
1063
+ status,
1064
+ headers: {
1065
+ "content-type": "application/problem+json",
1066
+ "cache-control": "no-store"
1067
+ }
1068
+ });
1069
+ }
1070
+ function now() {
1071
+ return Math.floor(Date.now() / 1e3);
1072
+ }
1073
+ //#endregion
1074
+ //#region src/sign-in-error.ts
1075
+ /** The query parameter the route mount adds to `returnTo` when a sign-in fails. */
1076
+ const SIGN_IN_ERROR_PARAM = "synq_error";
1077
+ /** An OAuth or SDK error code, as the route mount puts it there. */
1078
+ const CODE = /^[a-z0-9_]{1,64}$/;
1079
+ const MESSAGES = {
1080
+ access_denied: "The sign-in was cancelled, or this account can’t use this app.",
1081
+ login_required: "You need to sign in to continue.",
1082
+ interaction_required: "You need to sign in to continue.",
1083
+ consent_required: "You need to allow this app to use your account to continue.",
1084
+ account_selection_required: "Choose an account to continue.",
1085
+ temporarily_unavailable: "Signing in isn’t available just now. Try again in a moment.",
1086
+ unknown_state: "This sign-in expired, or it finished in another tab. Try again."
1087
+ };
1088
+ /** What a sign-in error code means, in plain words. */
1089
+ function signInErrorMessage(code) {
1090
+ return MESSAGES[code] ?? "Something went wrong signing in. Try again.";
1091
+ }
1092
+ /** A path and query on the app, without `synq_error`: where the ways on come back to. */
1093
+ function withoutSignInError(pathAndSearch) {
1094
+ const url = new URL(pathAndSearch, "http://app.invalid");
1095
+ url.searchParams.delete(SIGN_IN_ERROR_PARAM);
1096
+ return `${url.pathname}${url.search}`;
1097
+ }
1098
+ /**
1099
+ * The sign-in error `code` names, coming back to `returnTo` (a path on the
1100
+ * app), or null when there is none or it isn't a code.
1101
+ */
1102
+ function signInErrorOf(code, basePath, returnTo) {
1103
+ if (!code || !CODE.test(code)) return null;
1104
+ const back = withoutSignInError(returnTo.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/");
1105
+ const signIn = (prompt) => {
1106
+ const params = new URLSearchParams({ returnTo: back });
1107
+ if (prompt) params.set("prompt", prompt);
1108
+ return `${basePath}/sign-in?${params.toString()}`;
1109
+ };
1110
+ return {
1111
+ code,
1112
+ message: signInErrorMessage(code),
1113
+ retryPath: signIn(),
1114
+ anotherAccountPath: signIn("select_account")
1115
+ };
1116
+ }
1117
+ function escape(text) {
1118
+ return text.replace(/[&<>"']/g, (c) => ({
1119
+ "&": "&amp;",
1120
+ "<": "&lt;",
1121
+ ">": "&gt;",
1122
+ "\"": "&quot;",
1123
+ "'": "&#39;"
1124
+ })[c]);
1125
+ }
1126
+ /**
1127
+ * The proxy's own page for a sign-in error, for an app that has none of its
1128
+ * own (`synqProxy`'s `signInError`): plain HTML, no script and no style, so
1129
+ * any Content-Security-Policy the app sends allows it.
1130
+ */
1131
+ function signInErrorPage(error) {
1132
+ const html = `<!doctype html>
1133
+ <html lang="en">
1134
+ <head>
1135
+ <meta charset="utf-8">
1136
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1137
+ <meta name="robots" content="noindex">
1138
+ <title>Sign-in didn’t finish</title>
1139
+ </head>
1140
+ <body>
1141
+ <main>
1142
+ <h1>Sign-in didn’t finish</h1>
1143
+ <p>${escape(error.message)}</p>
1144
+ <p><a href="${escape(error.retryPath)}">Try again</a></p>
1145
+ <p><a href="${escape(error.anotherAccountPath)}">Use another account</a></p>
1146
+ </main>
1147
+ </body>
1148
+ </html>
1149
+ `;
1150
+ return new Response(html, {
1151
+ status: 200,
1152
+ headers: {
1153
+ "content-type": "text/html; charset=utf-8",
1154
+ "cache-control": "no-store",
1155
+ "x-content-type-options": "nosniff"
1156
+ }
1157
+ });
1158
+ }
1159
+ //#endregion
1160
+ export { withoutSignInError as a, memorySessionStore as c, normalizePath as d, resolveConfig as f, signInErrorPage as i, defineSynqPlugin as l, signInErrorMessage as n, createSynqCore as o, signInErrorOf as r, keyValueSessionStore as s, SIGN_IN_ERROR_PARAM as t, requestJar as u };