jskelet 0.5.3 → 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 CHANGED
@@ -10,6 +10,14 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - Shared cross-subdomain cookies: `brand.sharedCookieRoots` plus
14
+ `writeSharedCookie` / `clearSharedCookie` on server (`jskelet/cookies`) and
15
+ client (`jskelet/client`). `Secure` follows https / `x-forwarded-proto` (not
16
+ `NODE_ENV`); the client read-back fails into handoff when the browser rejects
17
+ `Domain`. Optional `auth.crossSubdomainHandoff` mounts
18
+ `POST /_jskelet/auth/handoff` (one-time ticket → `?handoff=`) and documents a
19
+ `window.name` bridge. Large tokens are refused — put a short session id in the
20
+ cookie, not a JWT.
13
21
  - HTML cache key vary (`cache().vary`): `host: true` adds the public Host
14
22
  (`x-forwarded-host` or `Host`, lowercase, no port) as `h=…|` before the path;
15
23
  optional `headers` and `fn(req)` add further segments. Required on host-based
@@ -186,14 +186,38 @@ birleştirilir.
186
186
  | `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | Isıtma isteklerinin UA'sı; dev paneli bunu filtreler |
187
187
  | `devTokenCookie` | `string` | `"dev_token"` | Dev gate'in çerez ve query parametresi adı |
188
188
  | `lang` | `string` | — | `<html lang>` varsayılanı. Verilmezse layout `"en"` kullanır. |
189
+ | `sharedCookieRoots` | `string[]` | `[]` | Paylaşımlı cookie Domain kökleri (örn. `.investvio.com`, `.localhost`). [12](./12-panel-ve-oturum.md) |
189
190
 
190
191
  `lang` için öncelik sırası: `hooks.layoutContext()` → `lang` **>** `brand.lang`
191
192
  **>** `"en"`.
192
193
 
193
194
  ```js
194
- brand: { lang: "tr", poweredBy: "Örnek", cacheHeader: "X-Ornek-Cache" }
195
+ brand: {
196
+ lang: "tr",
197
+ poweredBy: "Örnek",
198
+ sharedCookieRoots: [".investvio.com", ".localhost"],
199
+ }
195
200
  ```
196
201
 
202
+ ## `auth`
203
+
204
+ **Tip:** `object` — **Varsayılan:** `{ crossSubdomainHandoff: false }`
205
+
206
+ Kimlik framework'te yok; bu bölüm yalnızca alt alan adları arasında kısa
207
+ session id taşımak için handoff köprüsünü açar.
208
+
209
+ | Alan | Tip | Varsayılan | Anlamı |
210
+ | --- | --- | --- | --- |
211
+ | `crossSubdomainHandoff` | `boolean \| object` | `false` | `true` veya `{ ttlSeconds?, path?, maxValueBytes? }` → `POST /_jskelet/auth/handoff` + `?handoff=` redeem |
212
+
213
+ ```js
214
+ auth: {
215
+ crossSubdomainHandoff: { ttlSeconds: 60 },
216
+ },
217
+ ```
218
+
219
+ Ayrıntı ve `window.name` yedeği: [12-panel-ve-oturum.md](./12-panel-ve-oturum.md).
220
+
197
221
  ## `layout`
198
222
 
199
223
  **Tip:** `string` — **Varsayılan:** yok (otomatik çözüm)
@@ -129,6 +129,94 @@ kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
129
129
  Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
130
130
  veriyi değil, onun kimliğini koyun.
131
131
 
132
+ ## Alt alan adları: paylaşımlı cookie
133
+
134
+ Host tabanlı i18n (`tr.example.com` / `en.example.com`) oturumu alt alanlar
135
+ arasında paylaşmak ister. Çözüm **kısa session id** + isteğe bağlı
136
+ `Domain=.example.com` — JWT veya büyük access token paylaşımlı cookie'ye
137
+ konmaz (`large token ≠ shared cookie`).
138
+
139
+ ```js
140
+ // jskelet.config.mjs
141
+ export default {
142
+ brand: {
143
+ sharedCookieRoots: [".investvio.com", ".localhost"],
144
+ },
145
+ auth: {
146
+ crossSubdomainHandoff: true, // POST /_jskelet/auth/handoff
147
+ },
148
+ };
149
+ ```
150
+
151
+ ### Sunucu
152
+
153
+ ```js
154
+ import { writeSharedCookie } from "jskelet/cookies";
155
+
156
+ export function startSession(res, req, sessionId) {
157
+ const result = writeSharedCookie(res, "sid", sessionId, {
158
+ req,
159
+ maxAge: 60 * 60 * 8,
160
+ });
161
+ // result.ok === false → result.handoff; istemci handoff denemeli
162
+ return result;
163
+ }
164
+ ```
165
+
166
+ `Secure` **protokole** bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e
167
+ değil. Domain, isteğin Host'u `sharedCookieRoots` ile eşleşince yazılır.
168
+ Değer ~512 baytı aşarsa yazım reddedilir ve `handoff: true` döner.
169
+
170
+ ### İstemci
171
+
172
+ ```js
173
+ import {
174
+ writeSharedCookie,
175
+ createHandoffUrl,
176
+ handoffViaWindowName,
177
+ consumeWindowNameHandoff,
178
+ } from "jskelet/client";
179
+
180
+ const result = writeSharedCookie("sid", sessionId, {
181
+ roots: [".investvio.com", ".localhost"],
182
+ maxAge: 60 * 60 * 8,
183
+ });
184
+
185
+ if (!result.ok && result.handoff) {
186
+ const url = await createHandoffUrl({
187
+ name: "sid",
188
+ value: sessionId,
189
+ next: "https://tr.investvio.com/panel",
190
+ });
191
+ if (url) location.assign(url);
192
+ else handoffViaWindowName("https://tr.investvio.com/panel", {
193
+ name: "sid",
194
+ value: sessionId,
195
+ });
196
+ }
197
+
198
+ // Hedef host'ta (layout / island bootstrap):
199
+ consumeWindowNameHandoff();
200
+ ```
201
+
202
+ `roots` verilmezse `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`
203
+ okunur. Yazımdan sonra **read-back** yapılır; tarayıcı Domain'i reddettiyse
204
+ `handoff: true`.
205
+
206
+ ### Handoff bileti
207
+
208
+ `auth.crossSubdomainHandoff` açıkken:
209
+
210
+ 1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (`?handoff=` ekli)
211
+ 2. Hedef host'ta GET middleware bileti tek kullanımlık tüketir, cookie yazar
212
+ (önce shared Domain, olmazsa host-only), `handoff` query'siz 303
213
+
214
+ `next` yalnızca aynı `sharedCookieRoots` altındaki host'lara izinli. Bilet
215
+ ~60 sn, süreç belleğinde. JWT URL'ye konmaz.
216
+
217
+ `window.name` köprüsü cookie'siz yedek: kaynakta `handoffViaWindowName`,
218
+ hedeefte `consumeWindowNameHandoff`.
219
+
132
220
  ## CSRF
133
221
 
134
222
  Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
@@ -191,14 +191,39 @@ shallow-merged with the defaults.
191
191
  | `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
192
192
  | `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
193
193
  | `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
194
+ | `sharedCookieRoots` | `string[]` | `[]` | Shared cookie Domain roots (e.g. `.investvio.com`, `.localhost`). [12](./12-dashboards-and-sessions.md) |
194
195
 
195
196
  Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
196
197
  **>** `"en"`.
197
198
 
198
199
  ```js
199
- brand: { lang: "tr", poweredBy: "Example", cacheHeader: "X-Example-Cache" }
200
+ brand: {
201
+ lang: "tr",
202
+ poweredBy: "Example",
203
+ sharedCookieRoots: [".investvio.com", ".localhost"],
204
+ }
200
205
  ```
201
206
 
207
+ ## `auth`
208
+
209
+ **Type:** `object` — **Default:** `{ crossSubdomainHandoff: false }`
210
+
211
+ The framework does not provide identity; this section only opens the
212
+ cross-subdomain handoff bridge for a short session id.
213
+
214
+ | Field | Type | Default | Meaning |
215
+ | --- | --- | --- | --- |
216
+ | `crossSubdomainHandoff` | `boolean \| object` | `false` | `true` or `{ ttlSeconds?, path?, maxValueBytes? }` → `POST /_jskelet/auth/handoff` + `?handoff=` redeem |
217
+
218
+ ```js
219
+ auth: {
220
+ crossSubdomainHandoff: { ttlSeconds: 60 },
221
+ },
222
+ ```
223
+
224
+ Details and the `window.name` fallback:
225
+ [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
226
+
202
227
  ## `layout`
203
228
 
204
229
  **Type:** `string` — **Default:** none (automatic resolution)
@@ -131,6 +131,97 @@ cookie is simply not sent on cross-site POSTs.
131
131
  Cookies are **signed, not encrypted**. The value is readable, so store the
132
132
  identifier of a secret rather than the secret itself.
133
133
 
134
+ ## Cross-subdomain: shared cookies
135
+
136
+ Host-based i18n (`tr.example.com` / `en.example.com`) often needs a session on
137
+ every locale host. The supported pattern is a **short session id** plus an
138
+ optional `Domain=.example.com` — do not put a JWT or a large access token in a
139
+ shared cookie (`large token ≠ shared cookie`).
140
+
141
+ ```js
142
+ // jskelet.config.mjs
143
+ export default {
144
+ brand: {
145
+ sharedCookieRoots: [".investvio.com", ".localhost"],
146
+ },
147
+ auth: {
148
+ crossSubdomainHandoff: true, // POST /_jskelet/auth/handoff
149
+ },
150
+ };
151
+ ```
152
+
153
+ ### Server
154
+
155
+ ```js
156
+ import { writeSharedCookie } from "jskelet/cookies";
157
+
158
+ export function startSession(res, req, sessionId) {
159
+ const result = writeSharedCookie(res, "sid", sessionId, {
160
+ req,
161
+ maxAge: 60 * 60 * 8,
162
+ });
163
+ // result.ok === false → result.handoff; the client should use handoff
164
+ return result;
165
+ }
166
+ ```
167
+
168
+ `Secure` follows the **protocol** (`https` / `x-forwarded-proto`), not
169
+ `NODE_ENV`. The Domain is set when the request Host matches
170
+ `sharedCookieRoots`. Values larger than ~512 bytes are refused with
171
+ `handoff: true`.
172
+
173
+ ### Client
174
+
175
+ ```js
176
+ import {
177
+ writeSharedCookie,
178
+ createHandoffUrl,
179
+ handoffViaWindowName,
180
+ consumeWindowNameHandoff,
181
+ } from "jskelet/client";
182
+
183
+ const result = writeSharedCookie("sid", sessionId, {
184
+ roots: [".investvio.com", ".localhost"],
185
+ maxAge: 60 * 60 * 8,
186
+ });
187
+
188
+ if (!result.ok && result.handoff) {
189
+ const url = await createHandoffUrl({
190
+ name: "sid",
191
+ value: sessionId,
192
+ next: "https://tr.investvio.com/panel",
193
+ });
194
+ if (url) location.assign(url);
195
+ else handoffViaWindowName("https://tr.investvio.com/panel", {
196
+ name: "sid",
197
+ value: sessionId,
198
+ });
199
+ }
200
+
201
+ // On the target host (layout / island bootstrap):
202
+ consumeWindowNameHandoff();
203
+ ```
204
+
205
+ If `roots` is omitted, the client reads
206
+ `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`. After writing,
207
+ a **read-back** runs; if the browser rejected the Domain, `handoff: true`.
208
+
209
+ ### Handoff ticket
210
+
211
+ With `auth.crossSubdomainHandoff` on:
212
+
213
+ 1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (with `?handoff=`)
214
+ 2. On the target host a GET middleware redeems the one-time ticket, sets the
215
+ cookie (shared Domain first, else host-only), and 303-redirects without
216
+ `handoff`
217
+
218
+ `next` must be under the same `sharedCookieRoots`. Tickets live ~60s in process
219
+ memory. Do not put a JWT in the URL.
220
+
221
+ The `window.name` bridge is the cookie-less fallback:
222
+ `handoffViaWindowName` on the source page, `consumeWindowNameHandoff` on the
223
+ target.
224
+
134
225
  ## CSRF
135
226
 
136
227
  The framework parses the request body (`express.urlencoded` + `express.json`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "./client": "./src/client/index.js",
34
34
  "./html": "./src/views/helpers/html.js",
35
35
  "./tags": "./src/views/helpers/tags.js",
36
- "./cookies": "./src/http/cookies.js",
36
+ "./cookies": "./src/http/cookies-entry.js",
37
37
  "./log": "./src/log.mjs",
38
38
  "./register": "./src/runtime/register.mjs",
39
39
  "./layout": "./src/templates/layout.ejs"
@@ -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
+ }
@@ -453,4 +453,23 @@ export const DEFAULT_BRAND = {
453
453
  devBasePath: "/__jskelet/dev",
454
454
  prewarmUserAgent: "jskelet-prewarm",
455
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,
456
475
  };
@@ -21,6 +21,7 @@
21
21
  * maxEntries?: number,
22
22
  * data?: {...}, redis?: {...}, prewarm?: {...} }
23
23
  * admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
24
+ * auth → { crossSubdomainHandoff?: boolean | object }
24
25
  * logs → { console?, kinds?, file?, s3? }
25
26
  *
26
27
  * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
@@ -33,6 +34,7 @@ import { pathToFileURL } from "node:url";
33
34
  import { compilePattern, matchPattern } from "./pattern.js";
34
35
  import {
35
36
  DEFAULT_ADMIN,
37
+ DEFAULT_AUTH,
36
38
  DEFAULT_BRAND,
37
39
  DEFAULT_CLOUDFLARE,
38
40
  DEFAULT_DATA_CACHE,
@@ -126,6 +128,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
126
128
  * @property {Record<string, unknown>} prewarm
127
129
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
128
130
  * @property {Record<string, unknown>} brand
131
+ * @property {{ crossSubdomainHandoff: boolean | Record<string, unknown> }} auth
129
132
  * @property {Record<string, Function>} hooks
130
133
  * @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
131
134
  * @property {string[] | null} routes Açık route modülü listesi.
@@ -896,6 +899,59 @@ function normalizeNavigation(raw, brand) {
896
899
  * headerName: string }} csrf
897
900
  */
898
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
+
899
955
  /**
900
956
  * Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
901
957
  * yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
@@ -1132,7 +1188,8 @@ export async function loadConfig(options = {}) {
1132
1188
  prewarmPriority,
1133
1189
  } = normalizeCache(cache);
1134
1190
  const dirs = resolveDirs(root, source.paths);
1135
- const brand = { ...DEFAULT_BRAND, ...(source.brand ?? {}) };
1191
+ const brand = normalizeBrand(source.brand);
1192
+ const auth = normalizeAuth(source.auth);
1136
1193
 
1137
1194
  config = {
1138
1195
  root,
@@ -1157,6 +1214,7 @@ export async function loadConfig(options = {}) {
1157
1214
  prewarm,
1158
1215
  prewarmPriority,
1159
1216
  brand,
1217
+ auth,
1160
1218
  hooks: source.hooks ?? {},
1161
1219
  layout: resolveLayout(dirs, source.layout),
1162
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
+ }
package/src/index.js CHANGED
@@ -35,6 +35,13 @@ export {
35
35
  setCookie,
36
36
  setSignedCookie,
37
37
  } from "./http/cookies.js";
38
+ export {
39
+ clearSharedCookie,
40
+ requestIsHttps,
41
+ resolveSharedCookieDomain,
42
+ SHARED_COOKIE_WARN_BYTES,
43
+ writeSharedCookie,
44
+ } from "./http/shared-cookie.js";
38
45
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
39
46
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
40
47
  export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Alt alan adı handoff: kısa ömürlü tek kullanımlık bilet.
3
+ *
4
+ * Akış:
5
+ * 1. Paylaşımlı cookie yazımı başarısız (istemci read-back fail)
6
+ * 2. POST /_jskelet/auth/handoff { name, value, next }
7
+ * 3. Yanıt { url: "https://tr.…/path?handoff=TICKET" }
8
+ * 4. Hedef host'ta GET middleware bileti tüketir → Set-Cookie + 303
9
+ *
10
+ * Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
11
+ * kurulumlar için yeterli. Değer kısa session id olmalı.
12
+ */
13
+ import { getConfig } from "../../config/index.js";
14
+ import { randomToken, setCookie } from "../../http/cookies.js";
15
+ import {
16
+ SHARED_COOKIE_WARN_BYTES,
17
+ requestIsHttps,
18
+ writeSharedCookie,
19
+ } from "../../http/shared-cookie.js";
20
+ import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
21
+
22
+ /** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
23
+ const tickets = new Map();
24
+
25
+ const DEFAULT_TTL_SECONDS = 60;
26
+ const HANDOFF_PATH = "/_jskelet/auth/handoff";
27
+
28
+ /**
29
+ * @returns {{ enabled: boolean, ttlSeconds: number, path: string, maxValueBytes: number }}
30
+ */
31
+ function settings() {
32
+ const auth = getConfig().auth ?? {};
33
+ const raw = auth.crossSubdomainHandoff;
34
+ if (raw === false || raw == null) {
35
+ return {
36
+ enabled: false,
37
+ ttlSeconds: DEFAULT_TTL_SECONDS,
38
+ path: HANDOFF_PATH,
39
+ maxValueBytes: SHARED_COOKIE_WARN_BYTES,
40
+ };
41
+ }
42
+
43
+ if (raw === true) {
44
+ return {
45
+ enabled: true,
46
+ ttlSeconds: DEFAULT_TTL_SECONDS,
47
+ path: HANDOFF_PATH,
48
+ maxValueBytes: SHARED_COOKIE_WARN_BYTES,
49
+ };
50
+ }
51
+
52
+ const source = /** @type {Record<string, unknown>} */ (raw);
53
+ const ttl = Number(source.ttlSeconds);
54
+ const maxBytes = Number(source.maxValueBytes);
55
+ return {
56
+ enabled: source.enabled !== false,
57
+ ttlSeconds:
58
+ Number.isFinite(ttl) && ttl > 0 ? Math.min(300, Math.floor(ttl)) : DEFAULT_TTL_SECONDS,
59
+ path:
60
+ typeof source.path === "string" && source.path.startsWith("/")
61
+ ? source.path
62
+ : HANDOFF_PATH,
63
+ maxValueBytes:
64
+ Number.isFinite(maxBytes) && maxBytes > 0
65
+ ? Math.floor(maxBytes)
66
+ : SHARED_COOKIE_WARN_BYTES,
67
+ };
68
+ }
69
+
70
+ function sweep() {
71
+ const now = Date.now();
72
+ for (const [id, entry] of tickets) {
73
+ if (entry.exp <= now) tickets.delete(id);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * @param {string} next
79
+ * @param {import('express').Request} req
80
+ * @returns {URL | null}
81
+ */
82
+ function parseNext(next, req) {
83
+ try {
84
+ return new URL(next, `${req.protocol}://${req.get("host")}`);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @param {URL} url
92
+ * @param {import('express').Request} req
93
+ * @returns {boolean}
94
+ */
95
+ function nextAllowed(url, req) {
96
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
97
+
98
+ const roots = getConfig().brand?.sharedCookieRoots ?? [];
99
+ const targetHost = url.hostname.toLowerCase();
100
+ const sourceHost = String(req.hostname || "")
101
+ .toLowerCase()
102
+ .replace(/:\d+$/, "");
103
+
104
+ if (targetHost === sourceHost) return true;
105
+
106
+ const sourceRoot = resolveSharedCookieDomain(sourceHost, roots);
107
+ const targetRoot = resolveSharedCookieDomain(targetHost, roots);
108
+ return Boolean(sourceRoot && targetRoot && sourceRoot === targetRoot);
109
+ }
110
+
111
+ /**
112
+ * @param {import('express').Request} req
113
+ * @param {import('express').Response} res
114
+ * @param {{ name: string, value: string, maxAge?: number }} payload
115
+ * @returns {void}
116
+ */
117
+ function issueCookie(req, res, payload) {
118
+ const shared = writeSharedCookie(res, payload.name, payload.value, {
119
+ req,
120
+ maxAge: payload.maxAge,
121
+ });
122
+
123
+ if (shared.ok) return;
124
+
125
+ setCookie(res, payload.name, payload.value, {
126
+ path: "/",
127
+ httpOnly: true,
128
+ sameSite: "Lax",
129
+ secure: requestIsHttps(req),
130
+ maxAge: payload.maxAge,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * @param {string} ticketId
136
+ * @returns {{ name: string, value: string, maxAge?: number } | null}
137
+ */
138
+ function redeem(ticketId) {
139
+ sweep();
140
+ const entry = tickets.get(ticketId);
141
+ if (!entry) return null;
142
+ tickets.delete(ticketId);
143
+ if (entry.exp <= Date.now()) return null;
144
+ return { name: entry.name, value: entry.value, maxAge: entry.maxAge };
145
+ }
146
+
147
+ /**
148
+ * Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
149
+ *
150
+ * @param {import('express').Express} app
151
+ * @returns {void}
152
+ */
153
+ export function mountAuthHandoff(app) {
154
+ const cfg = settings();
155
+ if (!cfg.enabled) return;
156
+
157
+ app.post(cfg.path, (req, res) => {
158
+ sweep();
159
+
160
+ const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
161
+ const value = typeof req.body?.value === "string" ? req.body.value : "";
162
+ const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
163
+ const maxAge =
164
+ req.body?.maxAge != null && Number.isFinite(Number(req.body.maxAge))
165
+ ? Math.floor(Number(req.body.maxAge))
166
+ : undefined;
167
+
168
+ if (!name || !next) {
169
+ res.status(400).json({ ok: false, error: "name and next are required" });
170
+ return;
171
+ }
172
+
173
+ if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
174
+ res.status(400).json({
175
+ ok: false,
176
+ error: `value exceeds ${cfg.maxValueBytes} bytes; use a short session id`,
177
+ });
178
+ return;
179
+ }
180
+
181
+ const nextUrl = parseNext(next, req);
182
+ if (!nextUrl || !nextAllowed(nextUrl, req)) {
183
+ res.status(400).json({ ok: false, error: "next is not an allowed URL" });
184
+ return;
185
+ }
186
+
187
+ const id = randomToken(24);
188
+ tickets.set(id, {
189
+ name,
190
+ value,
191
+ maxAge,
192
+ exp: Date.now() + cfg.ttlSeconds * 1000,
193
+ });
194
+
195
+ nextUrl.searchParams.set("handoff", id);
196
+ res.setHeader("Cache-Control", "no-store");
197
+ res.json({ ok: true, url: nextUrl.href });
198
+ });
199
+
200
+ // Hedef host'ta `?handoff=` yakala — route render'ından önce.
201
+ app.use((req, res, next) => {
202
+ if (req.method !== "GET" && req.method !== "HEAD") return next();
203
+ const ticket = typeof req.query?.handoff === "string" ? req.query.handoff : "";
204
+ if (!ticket) return next();
205
+
206
+ const payload = redeem(ticket);
207
+ if (!payload) return next();
208
+
209
+ issueCookie(req, res, payload);
210
+
211
+ const url = new URL(req.originalUrl || req.url, `${req.protocol}://${req.get("host")}`);
212
+ url.searchParams.delete("handoff");
213
+ res.setHeader("Cache-Control", "no-store");
214
+ res.redirect(303, url.pathname + url.search + url.hash);
215
+ });
216
+ }
217
+
218
+ /** @returns {void} */
219
+ export function _resetHandoffTickets() {
220
+ tickets.clear();
221
+ }
222
+
223
+ /** @returns {number} */
224
+ export function _handoffTicketCount() {
225
+ return tickets.size;
226
+ }
@@ -21,6 +21,9 @@
21
21
  * body parser'dan önce, admin ile aynı katmanda.
22
22
  * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
23
23
  * maliyeti ödenmesin.
24
+ * 5b. auth handoff (açıksa) — POST bilet + `?handoff=` redeem; csrf'den
25
+ * önce değil sonra: JSON gövde parser'ı hazır olsun. Redeem GET route
26
+ * render'ından önce olmalı.
24
27
  * 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
25
28
  * Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
26
29
  * 7. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
@@ -149,6 +152,17 @@ export async function createApp(options = {}) {
149
152
  app.use(express.urlencoded({ extended: false, limit: "64kb" }));
150
153
  app.use(express.json({ limit: "256kb" }));
151
154
 
155
+ const handoff = config.auth?.crossSubdomainHandoff;
156
+ if (
157
+ handoff === true ||
158
+ (handoff &&
159
+ typeof handoff === "object" &&
160
+ /** @type {{ enabled?: boolean }} */ (handoff).enabled !== false)
161
+ ) {
162
+ const { mountAuthHandoff } = await import("./auth/handoff.js");
163
+ mountAuthHandoff(app);
164
+ }
165
+
152
166
  app.use(csrf());
153
167
 
154
168
  app.use(configRewrites("afterFiles"));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Paylaşımlı cookie Domain seçimi — sunucu ve istemci aynı kuralları kullanır.
3
+ *
4
+ * `brand.sharedCookieRoots` örn. `[".investvio.com", ".localhost"]`.
5
+ * Host bu köklerden birine uyuyorsa Domain olarak o kök yazılır.
6
+ */
7
+
8
+ /**
9
+ * @param {string} host Host veya Host:port.
10
+ * @returns {string} Lowercase, portsuz.
11
+ */
12
+ export function stripHostPort(host) {
13
+ const raw = String(host ?? "")
14
+ .split(",")[0]
15
+ .trim()
16
+ .toLowerCase();
17
+ if (!raw) return "";
18
+ if (raw.startsWith("[")) {
19
+ const end = raw.indexOf("]");
20
+ return end === -1 ? raw : raw.slice(0, end + 1);
21
+ }
22
+ const colon = raw.lastIndexOf(":");
23
+ if (colon === -1) return raw;
24
+ if (raw.indexOf(":") !== colon) return raw;
25
+ return raw.slice(0, colon);
26
+ }
27
+
28
+ /**
29
+ * Kökü `Domain=` biçimine getirir (başında nokta).
30
+ *
31
+ * @param {unknown} root
32
+ * @returns {string | null}
33
+ */
34
+ export function normalizeCookieRoot(root) {
35
+ if (typeof root !== "string") return null;
36
+ const trimmed = root.trim().toLowerCase();
37
+ if (!trimmed || trimmed === ".") return null;
38
+ const bare = trimmed.replace(/^\./, "");
39
+ if (!bare || bare.includes("/") || bare.includes(":")) return null;
40
+ return `.${bare}`;
41
+ }
42
+
43
+ /**
44
+ * @param {string} hostname Portsuz hostname.
45
+ * @param {Iterable<string>} roots `brand.sharedCookieRoots`.
46
+ * @returns {string | null} `Domain` değeri (başında nokta) ya da eşleşme yoksa null.
47
+ */
48
+ export function resolveSharedCookieDomain(hostname, roots) {
49
+ const host = stripHostPort(hostname);
50
+ if (!host) return null;
51
+
52
+ for (const entry of roots ?? []) {
53
+ const root = normalizeCookieRoot(entry);
54
+ if (!root) continue;
55
+ const bare = root.slice(1);
56
+ if (host === bare || host.endsWith(`.${bare}`)) return root;
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Paylaşımlı cookie için önerilen üst sınır. JWT / büyük token buraya
64
+ * sığmaz — kısa session id koyun; aksi halde handoff veya host-only cookie.
65
+ */
66
+ export const SHARED_COOKIE_WARN_BYTES = 512;