jskelet 0.6.3 → 0.6.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/AGENTS.md +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +5 -0
|
@@ -1,309 +1,309 @@
|
|
|
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
|
-
* Güvenlik: mint CSRF'den *sonra* mount edilir (create-app); cookie adı
|
|
14
|
-
* allowlist + RFC 6265 doğrulaması; bekleyen bilet ve IP başına mint sınırı.
|
|
15
|
-
*/
|
|
16
|
-
import { getConfig } from "../../config/index.js";
|
|
17
|
-
import { isValidCookieName, randomToken, setCookie } from "../../http/cookies.js";
|
|
18
|
-
import {
|
|
19
|
-
SHARED_COOKIE_WARN_BYTES,
|
|
20
|
-
requestIsHttps,
|
|
21
|
-
writeSharedCookie,
|
|
22
|
-
} from "../../http/shared-cookie.js";
|
|
23
|
-
import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
|
|
24
|
-
|
|
25
|
-
/** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
|
|
26
|
-
const tickets = new Map();
|
|
27
|
-
|
|
28
|
-
/** IP → { count, windowStart } — basit mint rate limit. */
|
|
29
|
-
/** @type {Map<string, { count: number, windowStart: number }>} */
|
|
30
|
-
const mintWindows = new Map();
|
|
31
|
-
|
|
32
|
-
const DEFAULT_TTL_SECONDS = 60;
|
|
33
|
-
const DEFAULT_MAX_PENDING = 256;
|
|
34
|
-
const DEFAULT_MAX_MINTS_PER_IP = 30;
|
|
35
|
-
const MINT_WINDOW_MS = 60_000;
|
|
36
|
-
const HANDOFF_PATH = "/_jskelet/auth/handoff";
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @returns {{
|
|
40
|
-
* enabled: boolean,
|
|
41
|
-
* ttlSeconds: number,
|
|
42
|
-
* path: string,
|
|
43
|
-
* maxValueBytes: number,
|
|
44
|
-
* allowedCookieNames: string[],
|
|
45
|
-
* maxPendingTickets: number,
|
|
46
|
-
* maxMintsPerIpPerMinute: number,
|
|
47
|
-
* }}
|
|
48
|
-
*/
|
|
49
|
-
function settings() {
|
|
50
|
-
const auth = getConfig().auth ?? {};
|
|
51
|
-
const raw = auth.crossSubdomainHandoff;
|
|
52
|
-
if (raw === false || raw == null) {
|
|
53
|
-
return {
|
|
54
|
-
enabled: false,
|
|
55
|
-
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
56
|
-
path: HANDOFF_PATH,
|
|
57
|
-
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
58
|
-
allowedCookieNames: [],
|
|
59
|
-
maxPendingTickets: DEFAULT_MAX_PENDING,
|
|
60
|
-
maxMintsPerIpPerMinute: DEFAULT_MAX_MINTS_PER_IP,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** @type {Record<string, unknown>} */
|
|
65
|
-
const source = raw === true ? {} : /** @type {Record<string, unknown>} */ (raw);
|
|
66
|
-
|
|
67
|
-
const ttl = Number(source.ttlSeconds);
|
|
68
|
-
const maxBytes = Number(source.maxValueBytes);
|
|
69
|
-
const maxPending = Number(source.maxPendingTickets);
|
|
70
|
-
const maxMints = Number(source.maxMintsPerIpPerMinute);
|
|
71
|
-
|
|
72
|
-
const namesRaw = source.allowedCookieNames;
|
|
73
|
-
/** @type {string[]} */
|
|
74
|
-
const allowedCookieNames = Array.isArray(namesRaw)
|
|
75
|
-
? namesRaw.filter((n) => typeof n === "string" && isValidCookieName(n.trim())).map((n) => n.trim())
|
|
76
|
-
: [];
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
enabled: source.enabled !== false,
|
|
80
|
-
ttlSeconds:
|
|
81
|
-
Number.isFinite(ttl) && ttl > 0 ? Math.min(300, Math.floor(ttl)) : DEFAULT_TTL_SECONDS,
|
|
82
|
-
path:
|
|
83
|
-
typeof source.path === "string" && source.path.startsWith("/")
|
|
84
|
-
? source.path
|
|
85
|
-
: HANDOFF_PATH,
|
|
86
|
-
maxValueBytes:
|
|
87
|
-
Number.isFinite(maxBytes) && maxBytes > 0
|
|
88
|
-
? Math.floor(maxBytes)
|
|
89
|
-
: SHARED_COOKIE_WARN_BYTES,
|
|
90
|
-
allowedCookieNames,
|
|
91
|
-
maxPendingTickets:
|
|
92
|
-
Number.isFinite(maxPending) && maxPending > 0
|
|
93
|
-
? Math.floor(maxPending)
|
|
94
|
-
: DEFAULT_MAX_PENDING,
|
|
95
|
-
maxMintsPerIpPerMinute:
|
|
96
|
-
Number.isFinite(maxMints) && maxMints > 0
|
|
97
|
-
? Math.floor(maxMints)
|
|
98
|
-
: DEFAULT_MAX_MINTS_PER_IP,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function sweep() {
|
|
103
|
-
const now = Date.now();
|
|
104
|
-
for (const [id, entry] of tickets) {
|
|
105
|
-
if (entry.exp <= now) tickets.delete(id);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* @param {string} ip
|
|
111
|
-
* @param {number} limit
|
|
112
|
-
* @returns {boolean} true ise mint'e izin var
|
|
113
|
-
*/
|
|
114
|
-
function allowMint(ip, limit) {
|
|
115
|
-
const now = Date.now();
|
|
116
|
-
const key = ip || "unknown";
|
|
117
|
-
const entry = mintWindows.get(key);
|
|
118
|
-
if (!entry || now - entry.windowStart >= MINT_WINDOW_MS) {
|
|
119
|
-
mintWindows.set(key, { count: 1, windowStart: now });
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
if (entry.count >= limit) return false;
|
|
123
|
-
entry.count += 1;
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* @param {string} next
|
|
129
|
-
* @param {import('express').Request} req
|
|
130
|
-
* @returns {URL | null}
|
|
131
|
-
*/
|
|
132
|
-
function parseNext(next, req) {
|
|
133
|
-
try {
|
|
134
|
-
return new URL(next, `${req.protocol}://${req.get("host")}`);
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* @param {URL} url
|
|
142
|
-
* @param {import('express').Request} req
|
|
143
|
-
* @returns {boolean}
|
|
144
|
-
*/
|
|
145
|
-
function nextAllowed(url, req) {
|
|
146
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
147
|
-
|
|
148
|
-
const roots = getConfig().brand?.sharedCookieRoots ?? [];
|
|
149
|
-
const targetHost = url.hostname.toLowerCase();
|
|
150
|
-
const sourceHost = String(req.hostname || "")
|
|
151
|
-
.toLowerCase()
|
|
152
|
-
.replace(/:\d+$/, "");
|
|
153
|
-
|
|
154
|
-
if (targetHost === sourceHost) return true;
|
|
155
|
-
|
|
156
|
-
const sourceRoot = resolveSharedCookieDomain(sourceHost, roots);
|
|
157
|
-
const targetRoot = resolveSharedCookieDomain(targetHost, roots);
|
|
158
|
-
return Boolean(sourceRoot && targetRoot && sourceRoot === targetRoot);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* @param {import('express').Request} req
|
|
163
|
-
* @param {import('express').Response} res
|
|
164
|
-
* @param {{ name: string, value: string, maxAge?: number }} payload
|
|
165
|
-
* @returns {void}
|
|
166
|
-
*/
|
|
167
|
-
function issueCookie(req, res, payload) {
|
|
168
|
-
const shared = writeSharedCookie(res, payload.name, payload.value, {
|
|
169
|
-
req,
|
|
170
|
-
maxAge: payload.maxAge,
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
if (shared.ok) return;
|
|
174
|
-
|
|
175
|
-
setCookie(res, payload.name, payload.value, {
|
|
176
|
-
path: "/",
|
|
177
|
-
httpOnly: true,
|
|
178
|
-
sameSite: "Lax",
|
|
179
|
-
secure: requestIsHttps(req),
|
|
180
|
-
maxAge: payload.maxAge,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* @param {string} ticketId
|
|
186
|
-
* @returns {{ name: string, value: string, maxAge?: number } | null}
|
|
187
|
-
*/
|
|
188
|
-
function redeem(ticketId) {
|
|
189
|
-
sweep();
|
|
190
|
-
const entry = tickets.get(ticketId);
|
|
191
|
-
if (!entry) return null;
|
|
192
|
-
tickets.delete(ticketId);
|
|
193
|
-
if (entry.exp <= Date.now()) return null;
|
|
194
|
-
return { name: entry.name, value: entry.value, maxAge: entry.maxAge };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
|
|
199
|
-
* create-app CSRF'den *sonra* çağırmalıdır.
|
|
200
|
-
*
|
|
201
|
-
* @param {import('express').Express} app
|
|
202
|
-
* @returns {void}
|
|
203
|
-
*/
|
|
204
|
-
export function mountAuthHandoff(app) {
|
|
205
|
-
const cfg = settings();
|
|
206
|
-
if (!cfg.enabled) return;
|
|
207
|
-
|
|
208
|
-
if (cfg.allowedCookieNames.length === 0) {
|
|
209
|
-
console.warn(
|
|
210
|
-
"[auth.handoff] enabled but allowedCookieNames is empty — " +
|
|
211
|
-
"POST mint will return 400 until you list cookie names in " +
|
|
212
|
-
"auth.crossSubdomainHandoff.allowedCookieNames",
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
app.post(cfg.path, (req, res) => {
|
|
217
|
-
sweep();
|
|
218
|
-
|
|
219
|
-
if (!allowMint(String(req.ip || ""), cfg.maxMintsPerIpPerMinute)) {
|
|
220
|
-
res.status(429).json({ ok: false, error: "too many handoff requests" });
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (tickets.size >= cfg.maxPendingTickets) {
|
|
225
|
-
res.status(503).json({ ok: false, error: "handoff ticket store is full" });
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
|
230
|
-
const value = typeof req.body?.value === "string" ? req.body.value : "";
|
|
231
|
-
const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
|
|
232
|
-
const maxAge =
|
|
233
|
-
req.body?.maxAge != null && Number.isFinite(Number(req.body.maxAge))
|
|
234
|
-
? Math.floor(Number(req.body.maxAge))
|
|
235
|
-
: undefined;
|
|
236
|
-
|
|
237
|
-
if (!name || !next) {
|
|
238
|
-
res.status(400).json({ ok: false, error: "name and next are required" });
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!isValidCookieName(name)) {
|
|
243
|
-
res.status(400).json({ ok: false, error: "invalid cookie name" });
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (cfg.allowedCookieNames.length === 0 || !cfg.allowedCookieNames.includes(name)) {
|
|
248
|
-
res.status(400).json({
|
|
249
|
-
ok: false,
|
|
250
|
-
error: "cookie name is not in allowedCookieNames",
|
|
251
|
-
});
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
|
|
256
|
-
res.status(400).json({
|
|
257
|
-
ok: false,
|
|
258
|
-
error: `value exceeds ${cfg.maxValueBytes} bytes; use a short session id`,
|
|
259
|
-
});
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const nextUrl = parseNext(next, req);
|
|
264
|
-
if (!nextUrl || !nextAllowed(nextUrl, req)) {
|
|
265
|
-
res.status(400).json({ ok: false, error: "next is not an allowed URL" });
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const id = randomToken(24);
|
|
270
|
-
tickets.set(id, {
|
|
271
|
-
name,
|
|
272
|
-
value,
|
|
273
|
-
maxAge,
|
|
274
|
-
exp: Date.now() + cfg.ttlSeconds * 1000,
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
nextUrl.searchParams.set("handoff", id);
|
|
278
|
-
res.setHeader("Cache-Control", "no-store");
|
|
279
|
-
res.json({ ok: true, url: nextUrl.href });
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
// Hedef host'ta `?handoff=` yakala — route render'ından önce.
|
|
283
|
-
app.use((req, res, next) => {
|
|
284
|
-
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
285
|
-
const ticket = typeof req.query?.handoff === "string" ? req.query.handoff : "";
|
|
286
|
-
if (!ticket) return next();
|
|
287
|
-
|
|
288
|
-
const payload = redeem(ticket);
|
|
289
|
-
if (!payload) return next();
|
|
290
|
-
|
|
291
|
-
issueCookie(req, res, payload);
|
|
292
|
-
|
|
293
|
-
const url = new URL(req.originalUrl || req.url, `${req.protocol}://${req.get("host")}`);
|
|
294
|
-
url.searchParams.delete("handoff");
|
|
295
|
-
res.setHeader("Cache-Control", "no-store");
|
|
296
|
-
res.redirect(303, url.pathname + url.search + url.hash);
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** @returns {void} */
|
|
301
|
-
export function _resetHandoffTickets() {
|
|
302
|
-
tickets.clear();
|
|
303
|
-
mintWindows.clear();
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** @returns {number} */
|
|
307
|
-
export function _handoffTicketCount() {
|
|
308
|
-
return tickets.size;
|
|
309
|
-
}
|
|
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
|
+
* Güvenlik: mint CSRF'den *sonra* mount edilir (create-app); cookie adı
|
|
14
|
+
* allowlist + RFC 6265 doğrulaması; bekleyen bilet ve IP başına mint sınırı.
|
|
15
|
+
*/
|
|
16
|
+
import { getConfig } from "../../config/index.js";
|
|
17
|
+
import { isValidCookieName, randomToken, setCookie } from "../../http/cookies.js";
|
|
18
|
+
import {
|
|
19
|
+
SHARED_COOKIE_WARN_BYTES,
|
|
20
|
+
requestIsHttps,
|
|
21
|
+
writeSharedCookie,
|
|
22
|
+
} from "../../http/shared-cookie.js";
|
|
23
|
+
import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
|
|
24
|
+
|
|
25
|
+
/** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
|
|
26
|
+
const tickets = new Map();
|
|
27
|
+
|
|
28
|
+
/** IP → { count, windowStart } — basit mint rate limit. */
|
|
29
|
+
/** @type {Map<string, { count: number, windowStart: number }>} */
|
|
30
|
+
const mintWindows = new Map();
|
|
31
|
+
|
|
32
|
+
const DEFAULT_TTL_SECONDS = 60;
|
|
33
|
+
const DEFAULT_MAX_PENDING = 256;
|
|
34
|
+
const DEFAULT_MAX_MINTS_PER_IP = 30;
|
|
35
|
+
const MINT_WINDOW_MS = 60_000;
|
|
36
|
+
const HANDOFF_PATH = "/_jskelet/auth/handoff";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @returns {{
|
|
40
|
+
* enabled: boolean,
|
|
41
|
+
* ttlSeconds: number,
|
|
42
|
+
* path: string,
|
|
43
|
+
* maxValueBytes: number,
|
|
44
|
+
* allowedCookieNames: string[],
|
|
45
|
+
* maxPendingTickets: number,
|
|
46
|
+
* maxMintsPerIpPerMinute: number,
|
|
47
|
+
* }}
|
|
48
|
+
*/
|
|
49
|
+
function settings() {
|
|
50
|
+
const auth = getConfig().auth ?? {};
|
|
51
|
+
const raw = auth.crossSubdomainHandoff;
|
|
52
|
+
if (raw === false || raw == null) {
|
|
53
|
+
return {
|
|
54
|
+
enabled: false,
|
|
55
|
+
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
56
|
+
path: HANDOFF_PATH,
|
|
57
|
+
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
58
|
+
allowedCookieNames: [],
|
|
59
|
+
maxPendingTickets: DEFAULT_MAX_PENDING,
|
|
60
|
+
maxMintsPerIpPerMinute: DEFAULT_MAX_MINTS_PER_IP,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @type {Record<string, unknown>} */
|
|
65
|
+
const source = raw === true ? {} : /** @type {Record<string, unknown>} */ (raw);
|
|
66
|
+
|
|
67
|
+
const ttl = Number(source.ttlSeconds);
|
|
68
|
+
const maxBytes = Number(source.maxValueBytes);
|
|
69
|
+
const maxPending = Number(source.maxPendingTickets);
|
|
70
|
+
const maxMints = Number(source.maxMintsPerIpPerMinute);
|
|
71
|
+
|
|
72
|
+
const namesRaw = source.allowedCookieNames;
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const allowedCookieNames = Array.isArray(namesRaw)
|
|
75
|
+
? namesRaw.filter((n) => typeof n === "string" && isValidCookieName(n.trim())).map((n) => n.trim())
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
enabled: source.enabled !== false,
|
|
80
|
+
ttlSeconds:
|
|
81
|
+
Number.isFinite(ttl) && ttl > 0 ? Math.min(300, Math.floor(ttl)) : DEFAULT_TTL_SECONDS,
|
|
82
|
+
path:
|
|
83
|
+
typeof source.path === "string" && source.path.startsWith("/")
|
|
84
|
+
? source.path
|
|
85
|
+
: HANDOFF_PATH,
|
|
86
|
+
maxValueBytes:
|
|
87
|
+
Number.isFinite(maxBytes) && maxBytes > 0
|
|
88
|
+
? Math.floor(maxBytes)
|
|
89
|
+
: SHARED_COOKIE_WARN_BYTES,
|
|
90
|
+
allowedCookieNames,
|
|
91
|
+
maxPendingTickets:
|
|
92
|
+
Number.isFinite(maxPending) && maxPending > 0
|
|
93
|
+
? Math.floor(maxPending)
|
|
94
|
+
: DEFAULT_MAX_PENDING,
|
|
95
|
+
maxMintsPerIpPerMinute:
|
|
96
|
+
Number.isFinite(maxMints) && maxMints > 0
|
|
97
|
+
? Math.floor(maxMints)
|
|
98
|
+
: DEFAULT_MAX_MINTS_PER_IP,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sweep() {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
for (const [id, entry] of tickets) {
|
|
105
|
+
if (entry.exp <= now) tickets.delete(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} ip
|
|
111
|
+
* @param {number} limit
|
|
112
|
+
* @returns {boolean} true ise mint'e izin var
|
|
113
|
+
*/
|
|
114
|
+
function allowMint(ip, limit) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const key = ip || "unknown";
|
|
117
|
+
const entry = mintWindows.get(key);
|
|
118
|
+
if (!entry || now - entry.windowStart >= MINT_WINDOW_MS) {
|
|
119
|
+
mintWindows.set(key, { count: 1, windowStart: now });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (entry.count >= limit) return false;
|
|
123
|
+
entry.count += 1;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} next
|
|
129
|
+
* @param {import('express').Request} req
|
|
130
|
+
* @returns {URL | null}
|
|
131
|
+
*/
|
|
132
|
+
function parseNext(next, req) {
|
|
133
|
+
try {
|
|
134
|
+
return new URL(next, `${req.protocol}://${req.get("host")}`);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {URL} url
|
|
142
|
+
* @param {import('express').Request} req
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
function nextAllowed(url, req) {
|
|
146
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
147
|
+
|
|
148
|
+
const roots = getConfig().brand?.sharedCookieRoots ?? [];
|
|
149
|
+
const targetHost = url.hostname.toLowerCase();
|
|
150
|
+
const sourceHost = String(req.hostname || "")
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/:\d+$/, "");
|
|
153
|
+
|
|
154
|
+
if (targetHost === sourceHost) return true;
|
|
155
|
+
|
|
156
|
+
const sourceRoot = resolveSharedCookieDomain(sourceHost, roots);
|
|
157
|
+
const targetRoot = resolveSharedCookieDomain(targetHost, roots);
|
|
158
|
+
return Boolean(sourceRoot && targetRoot && sourceRoot === targetRoot);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {import('express').Request} req
|
|
163
|
+
* @param {import('express').Response} res
|
|
164
|
+
* @param {{ name: string, value: string, maxAge?: number }} payload
|
|
165
|
+
* @returns {void}
|
|
166
|
+
*/
|
|
167
|
+
function issueCookie(req, res, payload) {
|
|
168
|
+
const shared = writeSharedCookie(res, payload.name, payload.value, {
|
|
169
|
+
req,
|
|
170
|
+
maxAge: payload.maxAge,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (shared.ok) return;
|
|
174
|
+
|
|
175
|
+
setCookie(res, payload.name, payload.value, {
|
|
176
|
+
path: "/",
|
|
177
|
+
httpOnly: true,
|
|
178
|
+
sameSite: "Lax",
|
|
179
|
+
secure: requestIsHttps(req),
|
|
180
|
+
maxAge: payload.maxAge,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} ticketId
|
|
186
|
+
* @returns {{ name: string, value: string, maxAge?: number } | null}
|
|
187
|
+
*/
|
|
188
|
+
function redeem(ticketId) {
|
|
189
|
+
sweep();
|
|
190
|
+
const entry = tickets.get(ticketId);
|
|
191
|
+
if (!entry) return null;
|
|
192
|
+
tickets.delete(ticketId);
|
|
193
|
+
if (entry.exp <= Date.now()) return null;
|
|
194
|
+
return { name: entry.name, value: entry.value, maxAge: entry.maxAge };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
|
|
199
|
+
* create-app CSRF'den *sonra* çağırmalıdır.
|
|
200
|
+
*
|
|
201
|
+
* @param {import('express').Express} app
|
|
202
|
+
* @returns {void}
|
|
203
|
+
*/
|
|
204
|
+
export function mountAuthHandoff(app) {
|
|
205
|
+
const cfg = settings();
|
|
206
|
+
if (!cfg.enabled) return;
|
|
207
|
+
|
|
208
|
+
if (cfg.allowedCookieNames.length === 0) {
|
|
209
|
+
console.warn(
|
|
210
|
+
"[auth.handoff] enabled but allowedCookieNames is empty — " +
|
|
211
|
+
"POST mint will return 400 until you list cookie names in " +
|
|
212
|
+
"auth.crossSubdomainHandoff.allowedCookieNames",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
app.post(cfg.path, (req, res) => {
|
|
217
|
+
sweep();
|
|
218
|
+
|
|
219
|
+
if (!allowMint(String(req.ip || ""), cfg.maxMintsPerIpPerMinute)) {
|
|
220
|
+
res.status(429).json({ ok: false, error: "too many handoff requests" });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (tickets.size >= cfg.maxPendingTickets) {
|
|
225
|
+
res.status(503).json({ ok: false, error: "handoff ticket store is full" });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
|
230
|
+
const value = typeof req.body?.value === "string" ? req.body.value : "";
|
|
231
|
+
const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
|
|
232
|
+
const maxAge =
|
|
233
|
+
req.body?.maxAge != null && Number.isFinite(Number(req.body.maxAge))
|
|
234
|
+
? Math.floor(Number(req.body.maxAge))
|
|
235
|
+
: undefined;
|
|
236
|
+
|
|
237
|
+
if (!name || !next) {
|
|
238
|
+
res.status(400).json({ ok: false, error: "name and next are required" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!isValidCookieName(name)) {
|
|
243
|
+
res.status(400).json({ ok: false, error: "invalid cookie name" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (cfg.allowedCookieNames.length === 0 || !cfg.allowedCookieNames.includes(name)) {
|
|
248
|
+
res.status(400).json({
|
|
249
|
+
ok: false,
|
|
250
|
+
error: "cookie name is not in allowedCookieNames",
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
|
|
256
|
+
res.status(400).json({
|
|
257
|
+
ok: false,
|
|
258
|
+
error: `value exceeds ${cfg.maxValueBytes} bytes; use a short session id`,
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const nextUrl = parseNext(next, req);
|
|
264
|
+
if (!nextUrl || !nextAllowed(nextUrl, req)) {
|
|
265
|
+
res.status(400).json({ ok: false, error: "next is not an allowed URL" });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const id = randomToken(24);
|
|
270
|
+
tickets.set(id, {
|
|
271
|
+
name,
|
|
272
|
+
value,
|
|
273
|
+
maxAge,
|
|
274
|
+
exp: Date.now() + cfg.ttlSeconds * 1000,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
nextUrl.searchParams.set("handoff", id);
|
|
278
|
+
res.setHeader("Cache-Control", "no-store");
|
|
279
|
+
res.json({ ok: true, url: nextUrl.href });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Hedef host'ta `?handoff=` yakala — route render'ından önce.
|
|
283
|
+
app.use((req, res, next) => {
|
|
284
|
+
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
285
|
+
const ticket = typeof req.query?.handoff === "string" ? req.query.handoff : "";
|
|
286
|
+
if (!ticket) return next();
|
|
287
|
+
|
|
288
|
+
const payload = redeem(ticket);
|
|
289
|
+
if (!payload) return next();
|
|
290
|
+
|
|
291
|
+
issueCookie(req, res, payload);
|
|
292
|
+
|
|
293
|
+
const url = new URL(req.originalUrl || req.url, `${req.protocol}://${req.get("host")}`);
|
|
294
|
+
url.searchParams.delete("handoff");
|
|
295
|
+
res.setHeader("Cache-Control", "no-store");
|
|
296
|
+
res.redirect(303, url.pathname + url.search + url.hash);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @returns {void} */
|
|
301
|
+
export function _resetHandoffTickets() {
|
|
302
|
+
tickets.clear();
|
|
303
|
+
mintWindows.clear();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @returns {number} */
|
|
307
|
+
export function _handoffTicketCount() {
|
|
308
|
+
return tickets.size;
|
|
309
|
+
}
|