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.
- package/CHANGELOG.md +23 -0
- package/docs/06-cache.md +62 -11
- package/docs/07-yapilandirma.md +54 -3
- package/docs/12-panel-ve-oturum.md +88 -0
- package/docs/en/06-caching.md +63 -12
- package/docs/en/07-configuration.md +56 -3
- package/docs/en/12-dashboards-and-sessions.md +91 -0
- package/package.json +2 -2
- package/src/client/index.js +10 -0
- package/src/client/shared-cookie.js +225 -0
- package/src/config/defaults.js +27 -0
- package/src/config/index.js +108 -3
- package/src/http/cookies-entry.js +20 -0
- package/src/http/cookies.js +2 -0
- package/src/http/shared-cookie.js +178 -0
- package/src/index.js +7 -0
- package/src/server/auth/handoff.js +226 -0
- package/src/server/cache-vary.js +113 -0
- package/src/server/create-app.js +14 -0
- package/src/server/html-cache.js +17 -7
- package/src/server/prewarm.js +78 -10
- package/src/server/render.js +16 -7
- package/src/shared/cookie-domain.js +66 -0
package/src/client/index.js
CHANGED
|
@@ -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
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
};
|
package/src/config/index.js
CHANGED
|
@@ -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 },
|
|
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"],
|
|
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 =
|
|
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";
|
package/src/http/cookies.js
CHANGED
|
@@ -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
|
+
}
|