jskelet 0.5.2 → 0.5.4

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.
@@ -33,3 +33,13 @@ export {
33
33
  getOverlayRoot,
34
34
  } from "./dom.js";
35
35
  export { startSafeImages } from "./safe-image.js";
36
+ export {
37
+ clearSharedCookie,
38
+ consumeWindowNameHandoff,
39
+ createHandoffUrl,
40
+ handoffViaWindowName,
41
+ readCookie,
42
+ resolveSharedCookieDomain,
43
+ SHARED_COOKIE_WARN_BYTES,
44
+ writeSharedCookie,
45
+ } from "./shared-cookie.js";
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Tarayıcıda paylaşımlı cookie yazma + read-back.
3
+ *
4
+ * `document.cookie` ile Domain=.example.com yazmak bazı tarayıcılarda
5
+ * (Public Suffix, ITP) sessizce başarısız olur. Yazımdan sonra okunamazsa
6
+ * `{ handoff: true }` döner — çağıran `createHandoffUrl` veya
7
+ * `handoffViaWindowName` kullanır.
8
+ *
9
+ * `roots` verilmezse `document.documentElement.dataset.jskeletCookieRoots`
10
+ * (virgülle ayrılmış) okunur; layout'ta
11
+ * `data-jskelet-cookie-roots=".investvio.com,.localhost"` basılabilir.
12
+ */
13
+ import {
14
+ SHARED_COOKIE_WARN_BYTES,
15
+ resolveSharedCookieDomain,
16
+ } from "../shared/cookie-domain.js";
17
+
18
+ export {
19
+ SHARED_COOKIE_WARN_BYTES,
20
+ resolveSharedCookieDomain,
21
+ stripHostPort,
22
+ } from "../shared/cookie-domain.js";
23
+
24
+ /**
25
+ * @returns {string[]}
26
+ */
27
+ function rootsFromDom() {
28
+ if (typeof document === "undefined") return [];
29
+ const raw = document.documentElement?.dataset?.jskeletCookieRoots ?? "";
30
+ return raw
31
+ .split(",")
32
+ .map((entry) => entry.trim())
33
+ .filter(Boolean);
34
+ }
35
+
36
+ /**
37
+ * @param {string} name
38
+ * @returns {string | null}
39
+ */
40
+ export function readCookie(name) {
41
+ if (typeof document === "undefined") return null;
42
+ const prefix = `${encodeURIComponent(name)}=`;
43
+ for (const part of document.cookie.split(";")) {
44
+ const trimmed = part.trim();
45
+ if (!trimmed.startsWith(prefix)) continue;
46
+ try {
47
+ return decodeURIComponent(trimmed.slice(prefix.length));
48
+ } catch {
49
+ return trimmed.slice(prefix.length);
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * @typedef {object} ClientSharedCookieResult
57
+ * @property {boolean} ok
58
+ * @property {string | null} domain
59
+ * @property {boolean} handoff
60
+ * @property {string} [reason]
61
+ */
62
+
63
+ /**
64
+ * @param {string} name
65
+ * @param {string} value
66
+ * @param {{ roots?: string[], maxAge?: number, path?: string, sameSite?: string,
67
+ * secure?: boolean }} [options]
68
+ * @returns {ClientSharedCookieResult}
69
+ */
70
+ export function writeSharedCookie(name, value, options = {}) {
71
+ if (typeof document === "undefined") {
72
+ return { ok: false, domain: null, handoff: true, reason: "no-document" };
73
+ }
74
+
75
+ const roots = options.roots ?? rootsFromDom();
76
+ const domain = resolveSharedCookieDomain(location.hostname, roots);
77
+ if (!domain) {
78
+ return { ok: false, domain: null, handoff: true, reason: "no-matching-root" };
79
+ }
80
+
81
+ const text = String(value ?? "");
82
+ if (new TextEncoder().encode(text).length > SHARED_COOKIE_WARN_BYTES) {
83
+ return { ok: false, domain, handoff: true, reason: "value-too-large" };
84
+ }
85
+
86
+ const secure = options.secure ?? location.protocol === "https:";
87
+ const parts = [
88
+ `${encodeURIComponent(name)}=${encodeURIComponent(text)}`,
89
+ `Path=${options.path ?? "/"}`,
90
+ `Domain=${domain}`,
91
+ `SameSite=${options.sameSite ?? "Lax"}`,
92
+ ];
93
+ if (options.maxAge !== undefined) {
94
+ parts.push(`Max-Age=${Math.floor(Number(options.maxAge))}`);
95
+ }
96
+ if (secure) parts.push("Secure");
97
+
98
+ document.cookie = parts.join("; ");
99
+
100
+ // Read-back: tarayıcı Domain'i reddettiyse cookie görünmez.
101
+ if (readCookie(name) !== text) {
102
+ return { ok: false, domain, handoff: true, reason: "readback-failed" };
103
+ }
104
+
105
+ return { ok: true, domain, handoff: false };
106
+ }
107
+
108
+ /**
109
+ * @param {string} name
110
+ * @param {{ roots?: string[], path?: string, secure?: boolean, sameSite?: string }} [options]
111
+ * @returns {ClientSharedCookieResult}
112
+ */
113
+ export function clearSharedCookie(name, options = {}) {
114
+ if (typeof document === "undefined") {
115
+ return { ok: false, domain: null, handoff: false, reason: "no-document" };
116
+ }
117
+
118
+ const roots = options.roots ?? rootsFromDom();
119
+ const domain = resolveSharedCookieDomain(location.hostname, roots);
120
+ if (!domain) {
121
+ return { ok: false, domain: null, handoff: false, reason: "no-matching-root" };
122
+ }
123
+
124
+ const secure = options.secure ?? location.protocol === "https:";
125
+ const parts = [
126
+ `${encodeURIComponent(name)}=`,
127
+ `Path=${options.path ?? "/"}`,
128
+ `Domain=${domain}`,
129
+ "Max-Age=0",
130
+ `SameSite=${options.sameSite ?? "Lax"}`,
131
+ ];
132
+ if (secure) parts.push("Secure");
133
+ document.cookie = parts.join("; ");
134
+
135
+ return { ok: true, domain, handoff: false };
136
+ }
137
+
138
+ /**
139
+ * Handoff bileti ister; dönen URL'ye gidilir (`?handoff=` taşır).
140
+ *
141
+ * @param {{ name: string, value: string, next: string }} body
142
+ * @param {{ path?: string }} [options]
143
+ * @returns {Promise<string | null>}
144
+ */
145
+ export async function createHandoffUrl(body, options = {}) {
146
+ const path = options.path ?? "/_jskelet/auth/handoff";
147
+ const response = await fetch(path, {
148
+ method: "POST",
149
+ headers: {
150
+ "content-type": "application/json",
151
+ accept: "application/json",
152
+ },
153
+ credentials: "same-origin",
154
+ body: JSON.stringify(body),
155
+ });
156
+
157
+ if (!response.ok) return null;
158
+ /** @type {{ ok?: boolean, url?: string }} */
159
+ const data = await response.json().catch(() => ({}));
160
+ return typeof data.url === "string" ? data.url : null;
161
+ }
162
+
163
+ /**
164
+ * first-party `window.name` köprüsü — cookie Domain reddedilince yedek.
165
+ * Kaynak sayfada hedefe gitmeden önce çağırın; hedefte
166
+ * `consumeWindowNameHandoff` okur.
167
+ *
168
+ * @param {string} targetUrl
169
+ * @param {{ name: string, value: string, maxAge?: number }} cookie
170
+ * @returns {void}
171
+ */
172
+ export function handoffViaWindowName(targetUrl, cookie) {
173
+ if (typeof window === "undefined") return;
174
+ window.name = JSON.stringify({
175
+ jskeletHandoff: 1,
176
+ cookie: {
177
+ name: cookie.name,
178
+ value: cookie.value,
179
+ maxAge: cookie.maxAge,
180
+ },
181
+ });
182
+ location.assign(targetUrl);
183
+ }
184
+
185
+ /**
186
+ * Hedef host'ta `window.name` içindeki handoff'u host-only cookie olarak yazar.
187
+ *
188
+ * @param {{ write?: typeof writeSharedCookie }} [options]
189
+ * Varsayılan: Domain denemeden host-only `document.cookie`.
190
+ * @returns {boolean} Bir şey yazıldı mı.
191
+ */
192
+ export function consumeWindowNameHandoff(options = {}) {
193
+ if (typeof window === "undefined") return false;
194
+
195
+ let payload;
196
+ try {
197
+ payload = JSON.parse(window.name || "");
198
+ } catch {
199
+ return false;
200
+ }
201
+
202
+ if (!payload || payload.jskeletHandoff !== 1 || !payload.cookie?.name) {
203
+ return false;
204
+ }
205
+
206
+ window.name = "";
207
+
208
+ const { name, value, maxAge } = payload.cookie;
209
+ const text = String(value ?? "");
210
+ const secure = location.protocol === "https:";
211
+ const parts = [
212
+ `${encodeURIComponent(name)}=${encodeURIComponent(text)}`,
213
+ "Path=/",
214
+ "SameSite=Lax",
215
+ ];
216
+ if (maxAge !== undefined) parts.push(`Max-Age=${Math.floor(Number(maxAge))}`);
217
+ if (secure) parts.push("Secure");
218
+ document.cookie = parts.join("; ");
219
+
220
+ if (typeof options.write === "function") {
221
+ options.write(name, text, { maxAge });
222
+ }
223
+
224
+ return readCookie(name) === text;
225
+ }
@@ -87,6 +87,13 @@ export const DEFAULT_PREWARM = {
87
87
  * @type {(string | RegExp)[]}
88
88
  */
89
89
  priority: [],
90
+ /**
91
+ * Klasik turda ısıtılacak origin listesi. Boşsa `http://127.0.0.1:<port>`.
92
+ * `cache().vary.host` açıkken locale host'ları buraya yazılmazsa yalnızca
93
+ * loopback anahtarı ısınır.
94
+ * @type {string[]}
95
+ */
96
+ origins: [],
90
97
  };
91
98
 
92
99
  /**
@@ -122,6 +129,7 @@ export const CLASSIC_PREWARM_KEYS = [
122
129
  "retryDelayMs",
123
130
  "rotate",
124
131
  "priority",
132
+ "origins",
125
133
  ];
126
134
 
127
135
  /**
@@ -445,4 +453,23 @@ export const DEFAULT_BRAND = {
445
453
  devBasePath: "/__jskelet/dev",
446
454
  prewarmUserAgent: "jskelet-prewarm",
447
455
  devTokenCookie: "dev_token",
456
+ /**
457
+ * Paylaşımlı cookie Domain kökleri (örn. `.investvio.com`, `.localhost`).
458
+ * `writeSharedCookie` host bunlardan birine uyuyorsa Domain olarak yazar.
459
+ * @type {string[]}
460
+ */
461
+ sharedCookieRoots: [],
462
+ };
463
+
464
+ /**
465
+ * Kimlik / alt alan handoff. Framework oturum vermez; yalnızca çapraz-subdomain
466
+ * cookie köprüsü opsiyoneldir.
467
+ */
468
+ export const DEFAULT_AUTH = {
469
+ /**
470
+ * `true` veya `{ ttlSeconds?, path?, maxValueBytes? }`.
471
+ * Açıkken `POST /_jskelet/auth/handoff` ve `?handoff=` redeem middleware'i.
472
+ * @type {boolean | { enabled?: boolean, ttlSeconds?: number, path?: string, maxValueBytes?: number }}
473
+ */
474
+ crossSubdomainHandoff: false,
448
475
  };
@@ -16,9 +16,12 @@
16
16
  * redirects() → [{ source, destination, permanent?, statusCode? }]
17
17
  * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
18
  * cache() → { html?: { [source]: saniye },
19
- * query?: { [source]: string[] | true }, maxEntries?: number,
19
+ * query?: { [source]: string[] | true },
20
+ * vary?: { host?: boolean, headers?: string[], fn?: Function },
21
+ * maxEntries?: number,
20
22
  * data?: {...}, redis?: {...}, prewarm?: {...} }
21
23
  * admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
24
+ * auth → { crossSubdomainHandoff?: boolean | object }
22
25
  * logs → { console?, kinds?, file?, s3? }
23
26
  *
24
27
  * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
@@ -31,6 +34,7 @@ import { pathToFileURL } from "node:url";
31
34
  import { compilePattern, matchPattern } from "./pattern.js";
32
35
  import {
33
36
  DEFAULT_ADMIN,
37
+ DEFAULT_AUTH,
34
38
  DEFAULT_BRAND,
35
39
  DEFAULT_CLOUDFLARE,
36
40
  DEFAULT_DATA_CACHE,
@@ -107,6 +111,10 @@ const CONFIG_FILE = "jskelet.config.mjs";
107
111
  * @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
108
112
  * Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
109
113
  * parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
114
+ * @property {{ host: boolean, headers: string[],
115
+ * fn: ((req: import('express').Request) => string | null | undefined) | null }} cacheVary
116
+ * Anahtara eklenen sabit parçalar (query allowlist'ten bağımsız). Host'tan
117
+ * locale üreten sitelerde `host: true` zorunlu.
110
118
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
111
119
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
112
120
  * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
@@ -120,6 +128,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
120
128
  * @property {Record<string, unknown>} prewarm
121
129
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
122
130
  * @property {Record<string, unknown>} brand
131
+ * @property {{ crossSubdomainHandoff: boolean | Record<string, unknown> }} auth
123
132
  * @property {Record<string, Function>} hooks
124
133
  * @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
125
134
  * @property {string[] | null} routes Açık route modülü listesi.
@@ -633,10 +642,42 @@ function normalizeQueryRules(raw) {
633
642
  return out;
634
643
  }
635
644
 
645
+ /**
646
+ * `cache().vary` → HTML anahtarına host / header / özel fn parçası.
647
+ *
648
+ * @param {unknown} raw
649
+ * @returns {ResolvedConfig["cacheVary"]}
650
+ */
651
+ function normalizeVary(raw) {
652
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
653
+ return { host: false, headers: [], fn: null };
654
+ }
655
+
656
+ const source = /** @type {Record<string, unknown>} */ (raw);
657
+ const headers = asArray(source.headers, "cache().vary.headers")
658
+ .filter((name) => typeof name === "string" && name)
659
+ .map((name) => String(name).toLowerCase());
660
+
661
+ /** @type {ResolvedConfig["cacheVary"]["fn"]} */
662
+ let fn = null;
663
+ if (typeof source.fn === "function") {
664
+ fn = /** @type {ResolvedConfig["cacheVary"]["fn"]} */ (source.fn);
665
+ } else if (source.fn != null) {
666
+ console.warn("[config] cache().vary.fn must be a function, ignoring it");
667
+ }
668
+
669
+ return {
670
+ host: source.host === true,
671
+ headers,
672
+ fn,
673
+ };
674
+ }
675
+
636
676
  /**
637
677
  * @param {unknown} raw
638
678
  * @returns {{ html: ResolvedConfig["html"],
639
- * cacheQuery: ResolvedConfig["cacheQuery"], htmlMaxEntries: number,
679
+ * cacheQuery: ResolvedConfig["cacheQuery"],
680
+ * cacheVary: ResolvedConfig["cacheVary"], htmlMaxEntries: number,
640
681
  * data: Record<string, unknown>, trackUpstream: boolean,
641
682
  * trackDependencies: boolean,
642
683
  * transientRetry: { attempts: number, delayMs: number },
@@ -664,6 +705,7 @@ function normalizeCache(raw) {
664
705
  return {
665
706
  html,
666
707
  cacheQuery: queryRules,
708
+ cacheVary: normalizeVary(raw?.vary),
667
709
  htmlMaxEntries:
668
710
  Number.isFinite(maxEntries) && maxEntries > 0
669
711
  ? Math.floor(maxEntries)
@@ -775,6 +817,12 @@ function normalizePrewarm(raw) {
775
817
  // birleşir. `onVisit` anahtarı çözülmüş nesnede her zaman durur.
776
818
  const classic = { ...source };
777
819
  delete classic.onVisit;
820
+
821
+ const origins = asArray(classic.origins, "cache().prewarm.origins")
822
+ .filter((value) => typeof value === "string" && /^https?:\/\//i.test(value))
823
+ .map(String);
824
+ classic.origins = origins;
825
+
778
826
  return {
779
827
  ...DEFAULT_PREWARM,
780
828
  ...classic,
@@ -851,6 +899,59 @@ function normalizeNavigation(raw, brand) {
851
899
  * headerName: string }} csrf
852
900
  */
853
901
 
902
+ /**
903
+ * @param {unknown} raw
904
+ * @returns {Record<string, unknown>}
905
+ */
906
+ function normalizeBrand(raw) {
907
+ const source = /** @type {Record<string, unknown>} */ (raw ?? {});
908
+ const roots = asArray(
909
+ source.sharedCookieRoots ?? DEFAULT_BRAND.sharedCookieRoots,
910
+ "brand.sharedCookieRoots",
911
+ )
912
+ .filter((entry) => typeof entry === "string")
913
+ .map((entry) => {
914
+ const trimmed = String(entry).trim().toLowerCase();
915
+ if (!trimmed) return null;
916
+ return trimmed.startsWith(".") ? trimmed : `.${trimmed}`;
917
+ })
918
+ .filter((entry) => entry !== null);
919
+
920
+ return {
921
+ ...DEFAULT_BRAND,
922
+ ...source,
923
+ sharedCookieRoots: roots,
924
+ };
925
+ }
926
+
927
+ /**
928
+ * @param {unknown} raw
929
+ * @returns {{ crossSubdomainHandoff: boolean | Record<string, unknown> }}
930
+ */
931
+ function normalizeAuth(raw) {
932
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
933
+ return { ...DEFAULT_AUTH };
934
+ }
935
+
936
+ const source = /** @type {Record<string, unknown>} */ (raw);
937
+ const handoff = source.crossSubdomainHandoff;
938
+
939
+ if (handoff === true || handoff === false || handoff == null) {
940
+ return {
941
+ crossSubdomainHandoff: handoff === true,
942
+ };
943
+ }
944
+
945
+ if (typeof handoff === "object" && !Array.isArray(handoff)) {
946
+ return { crossSubdomainHandoff: { ...handoff } };
947
+ }
948
+
949
+ console.warn(
950
+ "[config] auth.crossSubdomainHandoff must be boolean or object, ignoring it",
951
+ );
952
+ return { ...DEFAULT_AUTH };
953
+ }
954
+
854
955
  /**
855
956
  * Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
856
957
  * yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
@@ -1074,6 +1175,7 @@ export async function loadConfig(options = {}) {
1074
1175
  const {
1075
1176
  html,
1076
1177
  cacheQuery,
1178
+ cacheVary,
1077
1179
  htmlMaxEntries,
1078
1180
  data,
1079
1181
  trackUpstream,
@@ -1086,7 +1188,8 @@ export async function loadConfig(options = {}) {
1086
1188
  prewarmPriority,
1087
1189
  } = normalizeCache(cache);
1088
1190
  const dirs = resolveDirs(root, source.paths);
1089
- const brand = { ...DEFAULT_BRAND, ...(source.brand ?? {}) };
1191
+ const brand = normalizeBrand(source.brand);
1192
+ const auth = normalizeAuth(source.auth);
1090
1193
 
1091
1194
  config = {
1092
1195
  root,
@@ -1097,6 +1200,7 @@ export async function loadConfig(options = {}) {
1097
1200
  rewrites: normalizeRewrites(rewrites),
1098
1201
  html,
1099
1202
  cacheQuery,
1203
+ cacheVary,
1100
1204
  htmlMaxEntries,
1101
1205
  data,
1102
1206
  trackUpstream,
@@ -1110,6 +1214,7 @@ export async function loadConfig(options = {}) {
1110
1214
  prewarm,
1111
1215
  prewarmPriority,
1112
1216
  brand,
1217
+ auth,
1113
1218
  hooks: source.hooks ?? {},
1114
1219
  layout: resolveLayout(dirs, source.layout),
1115
1220
  routes: Array.isArray(source.routes) ? source.routes : null,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `jskelet/cookies` paket girişi — imzalı cookie + paylaşımlı Domain yüzeyi.
3
+ */
4
+ export {
5
+ clearCookie,
6
+ getSignedCookie,
7
+ parseCookies,
8
+ randomToken,
9
+ safeEqual,
10
+ serializeCookie,
11
+ setCookie,
12
+ setSignedCookie,
13
+ } from "./cookies.js";
14
+ export {
15
+ clearSharedCookie,
16
+ requestIsHttps,
17
+ resolveSharedCookieDomain,
18
+ SHARED_COOKIE_WARN_BYTES,
19
+ writeSharedCookie,
20
+ } from "./shared-cookie.js";
@@ -144,6 +144,7 @@ export function parseCookies(req) {
144
144
  * @property {Date} [expires]
145
145
  * @property {boolean} [httpOnly] Varsayılan `true`.
146
146
  * @property {boolean} [secure] Varsayılan: development dışında `true`.
147
+ * `writeSharedCookie` protokole bakarak ezer (`https` → Secure).
147
148
  * @property {"Strict" | "Lax" | "None"} [sameSite] Varsayılan `Lax`.
148
149
  */
149
150
 
@@ -255,3 +256,4 @@ export function getSignedCookie(req, name) {
255
256
  export function randomToken(bytes = 32) {
256
257
  return crypto.randomBytes(bytes).toString("base64url");
257
258
  }
259
+
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Alt alan adları arasında paylaşılan cookie yazma.
3
+ *
4
+ * `Secure` protokole bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e değil —
5
+ * yerel https veya prod http yanlış bayrak üretmesin.
6
+ *
7
+ * Domain `brand.sharedCookieRoots` ile seçilir. Eşleşme yoksa ya da değer çok
8
+ * büyükse `{ ok: false, handoff: true }` döner; çağıran handoff yoluna düşer.
9
+ *
10
+ * JWT'yi paylaşımlı cookie'ye koymayın: kısa session id koyun.
11
+ */
12
+ import { getConfig } from "../config/index.js";
13
+ import {
14
+ SHARED_COOKIE_WARN_BYTES,
15
+ resolveSharedCookieDomain,
16
+ stripHostPort,
17
+ } from "../shared/cookie-domain.js";
18
+ import { clearCookie, setCookie } from "./cookies.js";
19
+
20
+ export {
21
+ SHARED_COOKIE_WARN_BYTES,
22
+ resolveSharedCookieDomain,
23
+ stripHostPort,
24
+ } from "../shared/cookie-domain.js";
25
+
26
+ /**
27
+ * @param {{ secure?: boolean, protocol?: string,
28
+ * headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
29
+ * @returns {boolean}
30
+ */
31
+ export function requestIsHttps(req) {
32
+ if (!req) return false;
33
+ if (req.secure === true) return true;
34
+ if (typeof req.protocol === "string" && req.protocol === "https") return true;
35
+
36
+ const forwarded =
37
+ req.get?.("x-forwarded-proto") ??
38
+ (typeof req.headers?.["x-forwarded-proto"] === "string"
39
+ ? req.headers["x-forwarded-proto"]
40
+ : Array.isArray(req.headers?.["x-forwarded-proto"])
41
+ ? req.headers["x-forwarded-proto"][0]
42
+ : "");
43
+ const first = String(forwarded ?? "")
44
+ .split(",")[0]
45
+ .trim()
46
+ .toLowerCase();
47
+ return first === "https";
48
+ }
49
+
50
+ /**
51
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined,
52
+ * hostname?: string }} [req]
53
+ * @returns {string}
54
+ */
55
+ function hostFromReq(req) {
56
+ if (!req) return "";
57
+ const forwarded =
58
+ req.get?.("x-forwarded-host") ??
59
+ (typeof req.headers?.["x-forwarded-host"] === "string"
60
+ ? req.headers["x-forwarded-host"]
61
+ : "");
62
+ const raw =
63
+ forwarded ||
64
+ req.get?.("host") ||
65
+ (typeof req.headers?.host === "string" ? req.headers.host : "") ||
66
+ (typeof req.hostname === "string" ? req.hostname : "");
67
+ return stripHostPort(String(raw).split(",")[0]);
68
+ }
69
+
70
+ /**
71
+ * @returns {string[]}
72
+ */
73
+ function configuredRoots() {
74
+ try {
75
+ const roots = getConfig().brand?.sharedCookieRoots;
76
+ return Array.isArray(roots) ? roots.map(String) : [];
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @typedef {import('./cookies.js').CookieOptions} CookieOptions
84
+ *
85
+ * @typedef {object} SharedCookieResult
86
+ * @property {boolean} ok Domain seçildi ve Set-Cookie yazıldı.
87
+ * @property {string | null} domain Yazılan Domain ya da null.
88
+ * @property {boolean} handoff ok değilse çağıran handoff denemeli.
89
+ * @property {string} [reason] Teşhis.
90
+ */
91
+
92
+ /**
93
+ * Paylaşımlı Domain ile cookie yazar.
94
+ *
95
+ * @param {import('http').ServerResponse & { req?: import('http').IncomingMessage }} res
96
+ * @param {string} name
97
+ * @param {string} value Kısa session id — JWT değil.
98
+ * @param {CookieOptions & { req?: import('http').IncomingMessage, roots?: string[],
99
+ * host?: string }} [options]
100
+ * @returns {SharedCookieResult}
101
+ */
102
+ export function writeSharedCookie(res, name, value, options = {}) {
103
+ const { req: optReq, roots: optRoots, host: optHost, ...cookieOpts } = options;
104
+ const req = optReq ?? /** @type {{ req?: import('http').IncomingMessage }} */ (res).req;
105
+ const roots = optRoots ?? configuredRoots();
106
+ const host = optHost ? stripHostPort(optHost) : hostFromReq(req);
107
+ const domain = resolveSharedCookieDomain(host, roots);
108
+
109
+ if (!domain) {
110
+ return {
111
+ ok: false,
112
+ domain: null,
113
+ handoff: true,
114
+ reason: "no-matching-root",
115
+ };
116
+ }
117
+
118
+ const text = String(value ?? "");
119
+ if (Buffer.byteLength(text, "utf8") > SHARED_COOKIE_WARN_BYTES) {
120
+ console.warn(
121
+ `[cookies] shared cookie "${name}" is ${Buffer.byteLength(text, "utf8")} bytes; ` +
122
+ `prefer a short session id (≤${SHARED_COOKIE_WARN_BYTES}). Large tokens do not belong in shared cookies.`,
123
+ );
124
+ return {
125
+ ok: false,
126
+ domain,
127
+ handoff: true,
128
+ reason: "value-too-large",
129
+ };
130
+ }
131
+
132
+ const secure = cookieOpts.secure ?? requestIsHttps(req);
133
+
134
+ setCookie(res, name, text, {
135
+ ...cookieOpts,
136
+ domain,
137
+ secure,
138
+ // Paylaşımlı oturum kimliği JS'ye gerekmez; HttpOnly kalsın.
139
+ httpOnly: cookieOpts.httpOnly !== false,
140
+ sameSite: cookieOpts.sameSite ?? "Lax",
141
+ path: cookieOpts.path ?? "/",
142
+ });
143
+
144
+ // Sunucuda document.cookie read-back yok; Domain seçildiyse yazım başarılı sayılır.
145
+ // İstemci `writeSharedCookie` read-back yapar; başarısızsa handoff'a düşer.
146
+ return { ok: true, domain, handoff: false };
147
+ }
148
+
149
+ /**
150
+ * Aynı Domain ile cookie siler.
151
+ *
152
+ * @param {import('http').ServerResponse & { req?: import('http').IncomingMessage }} res
153
+ * @param {string} name
154
+ * @param {CookieOptions & { req?: import('http').IncomingMessage, roots?: string[],
155
+ * host?: string }} [options]
156
+ * @returns {SharedCookieResult}
157
+ */
158
+ export function clearSharedCookie(res, name, options = {}) {
159
+ const { req: optReq, roots: optRoots, host: optHost, ...cookieOpts } = options;
160
+ const req = optReq ?? /** @type {{ req?: import('http').IncomingMessage }} */ (res).req;
161
+ const roots = optRoots ?? configuredRoots();
162
+ const host = optHost ? stripHostPort(optHost) : hostFromReq(req);
163
+ const domain = resolveSharedCookieDomain(host, roots);
164
+
165
+ if (!domain) {
166
+ return { ok: false, domain: null, handoff: false, reason: "no-matching-root" };
167
+ }
168
+
169
+ const secure = cookieOpts.secure ?? requestIsHttps(req);
170
+ clearCookie(res, name, {
171
+ ...cookieOpts,
172
+ domain,
173
+ secure,
174
+ path: cookieOpts.path ?? "/",
175
+ });
176
+
177
+ return { ok: true, domain, handoff: false };
178
+ }