@adrianhall/cloudflare-toolkit 0.0.1
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/LICENSE +21 -0
- package/README.md +59 -0
- package/THIRD-PARTY-NOTICES.md +75 -0
- package/dist/cli/generate-wrangler-types/index.d.ts +1 -0
- package/dist/cli/generate-wrangler-types/index.js +329 -0
- package/dist/cli/generate-wrangler-types/index.js.map +1 -0
- package/dist/error-CLYcAvBM.d.ts +28 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors-Ciipq_zr.js +58 -0
- package/dist/errors-Ciipq_zr.js.map +1 -0
- package/dist/factory-BI5gVL_P.js +220 -0
- package/dist/factory-BI5gVL_P.js.map +1 -0
- package/dist/generators-D8WWEHa1.js +165 -0
- package/dist/generators-D8WWEHa1.js.map +1 -0
- package/dist/guards/index.d.ts +2 -0
- package/dist/guards/index.js +2 -0
- package/dist/guards-6K1CVAr5.js +61 -0
- package/dist/guards-6K1CVAr5.js.map +1 -0
- package/dist/hono/index.d.ts +347 -0
- package/dist/hono/index.js +307 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/index-434HN8jN.d.ts +43 -0
- package/dist/index-Byl-ZrCy.d.ts +138 -0
- package/dist/index-CUICemFw.d.ts +129 -0
- package/dist/index-DRIhR-Xn.d.ts +81 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/jwt-BvuKtvby.js +328 -0
- package/dist/jwt-BvuKtvby.js.map +1 -0
- package/dist/logging/index.d.ts +3 -0
- package/dist/logging/index.js +3 -0
- package/dist/logging-CGHjOVLM.js +24 -0
- package/dist/logging-CGHjOVLM.js.map +1 -0
- package/dist/policy-CvS6AvvD.js +20 -0
- package/dist/policy-CvS6AvvD.js.map +1 -0
- package/dist/problem-details/index.d.ts +4 -0
- package/dist/problem-details/index.js +3 -0
- package/dist/problem-details-CuRsLy3Q.js +37 -0
- package/dist/problem-details-CuRsLy3Q.js.map +1 -0
- package/dist/silent-CWpHE65X.js +652 -0
- package/dist/silent-CWpHE65X.js.map +1 -0
- package/dist/testing/index.d.ts +44 -0
- package/dist/testing/index.js +2 -0
- package/dist/types-Cx6NNILW.d.ts +44 -0
- package/dist/types-DCSMb1cp.d.ts +195 -0
- package/dist/types-DXboaWOT.d.ts +29 -0
- package/dist/vite/index.d.ts +79 -0
- package/dist/vite/index.js +417 -0
- package/dist/vite/index.js.map +1 -0
- package/package.json +137 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { n as ProblemDetailsError } from "../factory-BI5gVL_P.js";
|
|
2
|
+
import { n as contentTooLarge } from "../generators-D8WWEHa1.js";
|
|
3
|
+
import { a as buildCookieHeader, c as signDevJwt, i as JWT_HEADER, o as clearCookieHeader, r as EMAIL_HEADER, s as parseCookie, u as verifyDevJwt } from "../jwt-BvuKtvby.js";
|
|
4
|
+
import { t as matchPolicy } from "../policy-CvS6AvvD.js";
|
|
5
|
+
//#region src/lib/vite/login-page.ts
|
|
6
|
+
/**
|
|
7
|
+
* Render a self-contained HTML page with a dev login form.
|
|
8
|
+
*
|
|
9
|
+
* @param loginPath - The path the form submits to (handled by the plugin).
|
|
10
|
+
* @param redirectTo - The URL the user is returned to after login.
|
|
11
|
+
* @param users - Optional selectable identities. When provided the form shows a radio list plus
|
|
12
|
+
* a "custom email" option; when empty it falls back to a single email input.
|
|
13
|
+
* @param error - Optional error message to display.
|
|
14
|
+
* @returns A complete `<!DOCTYPE html>` document as a string.
|
|
15
|
+
*/
|
|
16
|
+
function renderViteLoginPage(loginPath, redirectTo, users = [], error) {
|
|
17
|
+
const errorHtml = error ? `<div class="error" role="alert">${escapeHtml(error)}</div>` : "";
|
|
18
|
+
const usersHtml = users.length > 0 ? renderUserChoices(users) : renderEmailInput();
|
|
19
|
+
return `<!DOCTYPE html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
24
|
+
<title>Developer Login</title>
|
|
25
|
+
<style>
|
|
26
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
27
|
+
body{
|
|
28
|
+
margin:0;font-family:system-ui,-apple-system,sans-serif;
|
|
29
|
+
display:flex;align-items:center;justify-content:center;
|
|
30
|
+
min-height:100vh;background:#f4f4f5;color:#18181b;
|
|
31
|
+
}
|
|
32
|
+
.card{
|
|
33
|
+
background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);
|
|
34
|
+
padding:2.5rem;width:100%;max-width:420px;
|
|
35
|
+
}
|
|
36
|
+
h1{margin:0 0 .25rem;font-size:1.5rem}
|
|
37
|
+
p.subtitle{margin:0 0 1.5rem;color:#52525b;font-size:.875rem}
|
|
38
|
+
fieldset{border:none;margin:0;padding:0}
|
|
39
|
+
legend{font-size:.875rem;font-weight:500;margin-bottom:.5rem;padding:0}
|
|
40
|
+
label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.375rem}
|
|
41
|
+
.user-option{
|
|
42
|
+
display:flex;align-items:center;gap:.625rem;
|
|
43
|
+
padding:.625rem .75rem;border:1px solid #71717a;border-radius:8px;
|
|
44
|
+
margin-bottom:.5rem;cursor:pointer;
|
|
45
|
+
}
|
|
46
|
+
.user-option:focus-within{border-color:#1d4ed8;box-shadow:0 0 0 3px rgba(29,78,216,.4)}
|
|
47
|
+
.user-option input{margin:0}
|
|
48
|
+
.user-option .meta{display:flex;flex-direction:column}
|
|
49
|
+
.user-option .name{font-weight:500}
|
|
50
|
+
.user-option .email{color:#52525b;font-size:.8125rem}
|
|
51
|
+
input[type="email"]{
|
|
52
|
+
width:100%;padding:.625rem .75rem;border:1px solid #71717a;
|
|
53
|
+
border-radius:8px;font-size:1rem;outline:none;
|
|
54
|
+
transition:border-color .15s;
|
|
55
|
+
}
|
|
56
|
+
input[type="email"]:focus{border-color:#1d4ed8;box-shadow:0 0 0 3px rgba(29,78,216,.4)}
|
|
57
|
+
button{
|
|
58
|
+
margin-top:1rem;width:100%;padding:.625rem;border:none;
|
|
59
|
+
border-radius:8px;background:#1d4ed8;color:#fff;
|
|
60
|
+
font-size:1rem;font-weight:500;cursor:pointer;
|
|
61
|
+
transition:background .15s;
|
|
62
|
+
}
|
|
63
|
+
button:hover{background:#1e40af}
|
|
64
|
+
.error{
|
|
65
|
+
background:#fef2f2;color:#991b1b;border:1px solid #fecaca;
|
|
66
|
+
padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.875rem;
|
|
67
|
+
}
|
|
68
|
+
.badge{
|
|
69
|
+
display:inline-block;background:#fef3c7;color:#92400e;
|
|
70
|
+
font-size:.75rem;font-weight:600;padding:.125rem .5rem;
|
|
71
|
+
border-radius:9999px;margin-bottom:1rem;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<main class="card">
|
|
77
|
+
<span class="badge">LOCAL DEV</span>
|
|
78
|
+
<h1>Developer Login</h1>
|
|
79
|
+
<p class="subtitle">Simulates Cloudflare Access in front of your Vite dev server.</p>
|
|
80
|
+
${errorHtml}
|
|
81
|
+
<form method="POST" action="${escapeHtml(loginPath)}">
|
|
82
|
+
<input type="hidden" name="redirect" value="${escapeHtml(redirectTo)}" />
|
|
83
|
+
${usersHtml}
|
|
84
|
+
<button type="submit">Sign in</button>
|
|
85
|
+
</form>
|
|
86
|
+
</main>
|
|
87
|
+
</body>
|
|
88
|
+
</html>`;
|
|
89
|
+
}
|
|
90
|
+
/** Single email input (no pre-configured users). */
|
|
91
|
+
function renderEmailInput() {
|
|
92
|
+
return `<label for="email">Email address</label>
|
|
93
|
+
<input
|
|
94
|
+
id="email"
|
|
95
|
+
name="email"
|
|
96
|
+
type="email"
|
|
97
|
+
required
|
|
98
|
+
autocomplete="email"
|
|
99
|
+
placeholder="you@example.com"
|
|
100
|
+
autofocus
|
|
101
|
+
/>`;
|
|
102
|
+
}
|
|
103
|
+
/** Radio list of selectable identities plus a custom-email field. */
|
|
104
|
+
function renderUserChoices(users) {
|
|
105
|
+
return `<fieldset>
|
|
106
|
+
<legend>Choose an identity</legend>
|
|
107
|
+
${users.map((user, index) => {
|
|
108
|
+
const id = `user-${index}`;
|
|
109
|
+
const nameHtml = user.name ? `<span class="name">${escapeHtml(user.name)}</span>` : `<span class="name">${escapeHtml(user.email)}</span>`;
|
|
110
|
+
const emailHtml = user.name ? `<span class="email">${escapeHtml(user.email)}</span>` : "";
|
|
111
|
+
const checked = index === 0 ? " checked" : "";
|
|
112
|
+
return `<label class="user-option" for="${id}">
|
|
113
|
+
<input type="radio" id="${id}" name="email" value="${escapeHtml(user.email)}"${checked} />
|
|
114
|
+
<span class="meta">${nameHtml}${emailHtml}</span>
|
|
115
|
+
</label>`;
|
|
116
|
+
}).join("\n ")}
|
|
117
|
+
</fieldset>
|
|
118
|
+
<label for="custom-email">Or enter a custom email address</label>
|
|
119
|
+
<input
|
|
120
|
+
id="custom-email"
|
|
121
|
+
name="custom-email"
|
|
122
|
+
type="email"
|
|
123
|
+
autocomplete="email"
|
|
124
|
+
placeholder="you@example.com"
|
|
125
|
+
/>`;
|
|
126
|
+
}
|
|
127
|
+
/** Minimal HTML-entity escaping for untrusted values injected into HTML. */
|
|
128
|
+
function escapeHtml(value) {
|
|
129
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/lib/vite/plugin.ts
|
|
133
|
+
const DEFAULT_LOGIN_PATH = "/cdn-cgi/access/login";
|
|
134
|
+
const LOGOUT_PATH = "/cdn-cgi/access/logout";
|
|
135
|
+
const GET_IDENTITY_PATH = "/cdn-cgi/access/get-identity";
|
|
136
|
+
/**
|
|
137
|
+
* Path prefixes that belong to Vite's own internals (or asset requests) and must always pass
|
|
138
|
+
* through untouched.
|
|
139
|
+
*/
|
|
140
|
+
const VITE_INTERNAL_PREFIXES = [
|
|
141
|
+
"/@vite",
|
|
142
|
+
"/@fs",
|
|
143
|
+
"/@id",
|
|
144
|
+
"/@react-refresh",
|
|
145
|
+
"/node_modules/",
|
|
146
|
+
"/__vite",
|
|
147
|
+
"/src/"
|
|
148
|
+
];
|
|
149
|
+
/**
|
|
150
|
+
* Create the dev-only Cloudflare Access emulation plugin.
|
|
151
|
+
*
|
|
152
|
+
* Register it **before** `@cloudflare/vite-plugin` (and any framework plugin) so its connect
|
|
153
|
+
* middleware runs first:
|
|
154
|
+
*
|
|
155
|
+
* ```ts
|
|
156
|
+
* plugins: [cloudflareAccessPlugin(), cloudflare(), react()]
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* The middleware is registered synchronously in the `configureServer` hook body (combined with
|
|
160
|
+
* `enforce: "pre"`) so that it sits ahead of the request → `workerd` dispatch handler that
|
|
161
|
+
* `@cloudflare/vite-plugin` registers from its post hook.
|
|
162
|
+
*
|
|
163
|
+
* @param options - Configuration for path policies, the dev secret, selectable login identities,
|
|
164
|
+
* the login form path, and the dev token lifetime.
|
|
165
|
+
* @returns A Vite `Plugin`.
|
|
166
|
+
*/
|
|
167
|
+
function cloudflareAccessPlugin(options = {}) {
|
|
168
|
+
return {
|
|
169
|
+
name: "cloudflare-access-dev",
|
|
170
|
+
apply: "serve",
|
|
171
|
+
enforce: "pre",
|
|
172
|
+
configureServer(server) {
|
|
173
|
+
server.middlewares.use(createAccessDevMiddleware(options));
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Build the connect middleware that emulates Cloudflare Access.
|
|
179
|
+
*
|
|
180
|
+
* Exported separately so it can be unit-tested with mock `req`/`res` objects without booting a
|
|
181
|
+
* real Vite server.
|
|
182
|
+
*
|
|
183
|
+
* @param options - Same options accepted by {@link cloudflareAccessPlugin}.
|
|
184
|
+
* @returns A Vite `Connect.NextHandleFunction`.
|
|
185
|
+
*/
|
|
186
|
+
function createAccessDevMiddleware(options = {}) {
|
|
187
|
+
const policies = options.policies;
|
|
188
|
+
const devSecret = options.devSecret ?? "cloudflare-access-dev-secret-do-not-use-in-production";
|
|
189
|
+
const users = options.users ?? [];
|
|
190
|
+
const loginPath = options.loginPath ?? DEFAULT_LOGIN_PATH;
|
|
191
|
+
const tokenLifetime = options.tokenLifetime;
|
|
192
|
+
return (req, res, next) => {
|
|
193
|
+
handle(req, res, next).catch((err) => next(err));
|
|
194
|
+
};
|
|
195
|
+
async function handle(req, res, next) {
|
|
196
|
+
const pathname = getPathname(req);
|
|
197
|
+
if (isViteInternal(pathname)) return next();
|
|
198
|
+
if (pathname === loginPath) {
|
|
199
|
+
if (req.method === "POST") return handleLoginSubmit(req, res);
|
|
200
|
+
return serveLoginForm(req, res);
|
|
201
|
+
}
|
|
202
|
+
if (pathname === LOGOUT_PATH) return handleLogout(res);
|
|
203
|
+
if (pathname === GET_IDENTITY_PATH) return handleGetIdentity(req, res);
|
|
204
|
+
const token = parseCookie(req.headers.cookie);
|
|
205
|
+
const verified = token ? await verifyDevJwt(token, devSecret) : null;
|
|
206
|
+
if (token && verified) {
|
|
207
|
+
injectAccessHeaders(req, token, verified.email);
|
|
208
|
+
return next();
|
|
209
|
+
}
|
|
210
|
+
const policyMatch = policies ? matchPolicy(pathname, policies) : void 0;
|
|
211
|
+
if (policyMatch?.authenticate === false) return next();
|
|
212
|
+
if (policyMatch?.authenticate === true && policyMatch.redirect === false) return sendJson(res, 401, { error: "Authentication required" });
|
|
213
|
+
if (isNavigation(req)) return redirectToLogin(res, loginPath, pathname);
|
|
214
|
+
return next();
|
|
215
|
+
}
|
|
216
|
+
function serveLoginForm(req, res) {
|
|
217
|
+
const redirect = sanitizeRedirectTarget(getQueryParam(req, "redirect") ?? "/");
|
|
218
|
+
sendHtml(res, 200, renderViteLoginPage(loginPath, redirect, users));
|
|
219
|
+
}
|
|
220
|
+
async function handleLoginSubmit(req, res) {
|
|
221
|
+
let body;
|
|
222
|
+
try {
|
|
223
|
+
body = await readFormBody(req);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err instanceof ProblemDetailsError) {
|
|
226
|
+
await sendProblemDetails(res, err);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
const custom = typeof body["custom-email"] === "string" ? body["custom-email"].trim() : "";
|
|
232
|
+
const selected = typeof body.email === "string" ? body.email.trim() : "";
|
|
233
|
+
const email = custom || selected;
|
|
234
|
+
const redirect = sanitizeRedirectTarget(typeof body.redirect === "string" && body.redirect ? body.redirect : "/");
|
|
235
|
+
if (!email) {
|
|
236
|
+
sendHtml(res, 400, renderViteLoginPage(loginPath, redirect, users, "A valid email address is required."));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const sub = users.find((u) => u.email === email)?.sub;
|
|
240
|
+
const token = await signDevJwt(email, {
|
|
241
|
+
secret: devSecret,
|
|
242
|
+
lifetime: tokenLifetime,
|
|
243
|
+
sub
|
|
244
|
+
});
|
|
245
|
+
res.setHeader("Set-Cookie", buildCookieHeader(token, isSecure(req)));
|
|
246
|
+
redirectTo(res, redirect);
|
|
247
|
+
}
|
|
248
|
+
function handleLogout(res) {
|
|
249
|
+
res.setHeader("Set-Cookie", clearCookieHeader());
|
|
250
|
+
redirectTo(res, "/");
|
|
251
|
+
}
|
|
252
|
+
async function handleGetIdentity(req, res) {
|
|
253
|
+
const token = parseCookie(req.headers.cookie);
|
|
254
|
+
const verified = token ? await verifyDevJwt(token, devSecret) : null;
|
|
255
|
+
if (!verified) {
|
|
256
|
+
sendJson(res, 401, { error: "Authentication required" });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const display = users.find((u) => u.email === verified.email)?.name;
|
|
260
|
+
sendJson(res, 200, buildIdentity(verified.email, verified.sub, display));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Inject the Cloudflare Access headers so the request reaches the Worker authenticated.
|
|
264
|
+
*
|
|
265
|
+
* `@cloudflare/vite-plugin` builds the `Request` it dispatches into `workerd` from
|
|
266
|
+
* `req.rawHeaders` (not the parsed `req.headers` object), so the JWT **must** be pushed onto
|
|
267
|
+
* `rawHeaders`. We also mirror it onto `req.headers` so other connect middleware observe a
|
|
268
|
+
* consistent view.
|
|
269
|
+
*/
|
|
270
|
+
function injectAccessHeaders(req, token, email) {
|
|
271
|
+
req.rawHeaders.push(JWT_HEADER, token, EMAIL_HEADER, email);
|
|
272
|
+
req.headers[JWT_HEADER] = token;
|
|
273
|
+
req.headers[EMAIL_HEADER] = email;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Build a Cloudflare-Access-shaped identity object for the `get-identity` endpoint. Mirrors the
|
|
278
|
+
* real response closely enough for client code that reads `email`, `name`, `groups`, etc.
|
|
279
|
+
*
|
|
280
|
+
* @param email - Authenticated user's email.
|
|
281
|
+
* @param sub - Authenticated user's subject identifier.
|
|
282
|
+
* @param name - Optional display name; falls back to `email` when omitted.
|
|
283
|
+
* @returns A Cloudflare-Access-shaped identity object.
|
|
284
|
+
*/
|
|
285
|
+
function buildIdentity(email, sub, name) {
|
|
286
|
+
return {
|
|
287
|
+
id: sub,
|
|
288
|
+
name: name ?? email,
|
|
289
|
+
email,
|
|
290
|
+
user_uuid: sub,
|
|
291
|
+
account_id: "dev-account",
|
|
292
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
293
|
+
groups: [],
|
|
294
|
+
idp: {
|
|
295
|
+
id: "dev",
|
|
296
|
+
type: "dev-authentication"
|
|
297
|
+
},
|
|
298
|
+
geo: { country: "US" },
|
|
299
|
+
type: "dev"
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function getPathname(req) {
|
|
303
|
+
const raw = req.url ?? "/";
|
|
304
|
+
const queryIndex = raw.indexOf("?");
|
|
305
|
+
return queryIndex === -1 ? raw : raw.slice(0, queryIndex);
|
|
306
|
+
}
|
|
307
|
+
function getQueryParam(req, key) {
|
|
308
|
+
const path = req.url ?? "/";
|
|
309
|
+
return new URL(path, "http://localhost").searchParams.get(key) ?? void 0;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Restrict a client-supplied `redirect` target (the dev login form's query string / POST body
|
|
313
|
+
* field) to a same-origin, root-relative path — SEC-005
|
|
314
|
+
* (https://github.com/adrianhall/cloudflare-toolkit/issues/50).
|
|
315
|
+
*
|
|
316
|
+
* Only a value starting with a single `/` — not `//` or `/\`, both of which a browser can
|
|
317
|
+
* interpret as protocol-relative / scheme-relative and resolve against an attacker-controlled
|
|
318
|
+
* host — is considered safe. Anything else (an absolute URL, a protocol-relative URL, or any
|
|
319
|
+
* other unexpected value) falls back to `"/"`. Applied at both points a client-supplied
|
|
320
|
+
* `redirect` value is read (the login form's query string and the login submission's POST body)
|
|
321
|
+
* so the sanitized value is what flows into both the login form's hidden field and the actual
|
|
322
|
+
* redirect `Location` header.
|
|
323
|
+
*/
|
|
324
|
+
function sanitizeRedirectTarget(value) {
|
|
325
|
+
return /^\/(?!\/|\\)/.test(value) ? value : "/";
|
|
326
|
+
}
|
|
327
|
+
function isViteInternal(pathname) {
|
|
328
|
+
return VITE_INTERNAL_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* A request is treated as an HTML navigation when the browser flags it as such
|
|
332
|
+
* (`Sec-Fetch-Mode: navigate`) or it accepts `text/html`.
|
|
333
|
+
*/
|
|
334
|
+
function isNavigation(req) {
|
|
335
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
336
|
+
const fetchMode = headerValue(req, "sec-fetch-mode");
|
|
337
|
+
if (fetchMode) return fetchMode === "navigate";
|
|
338
|
+
return headerValue(req, "accept")?.includes("text/html") ?? false;
|
|
339
|
+
}
|
|
340
|
+
function isSecure(req) {
|
|
341
|
+
return headerValue(req, "x-forwarded-proto") === "https";
|
|
342
|
+
}
|
|
343
|
+
function headerValue(req, name) {
|
|
344
|
+
const value = req.headers[name];
|
|
345
|
+
if (Array.isArray(value)) return value[0];
|
|
346
|
+
return value ?? void 0;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Maximum accumulated size, in bytes, accepted for the dev login form's
|
|
350
|
+
* `application/x-www-form-urlencoded` POST body by {@link readFormBody}. The form only ever
|
|
351
|
+
* posts a handful of small fields (`email`, `custom-email`, `redirect`), so 64 KiB is a generous
|
|
352
|
+
* safety cap against an unbounded read — not a real-world limit — see CODE-008.
|
|
353
|
+
*/
|
|
354
|
+
const MAX_FORM_BODY_BYTES = 64 * 1024;
|
|
355
|
+
/**
|
|
356
|
+
* Read and parse an `application/x-www-form-urlencoded` request body, capped at
|
|
357
|
+
* {@link MAX_FORM_BODY_BYTES}. Rejects with a `413 Content Too Large`
|
|
358
|
+
* {@link ProblemDetailsError} (via `contentTooLarge`) once the cap is exceeded, after destroying
|
|
359
|
+
* the underlying stream so no further data is buffered.
|
|
360
|
+
*/
|
|
361
|
+
function readFormBody(req) {
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
const chunks = [];
|
|
364
|
+
let receivedBytes = 0;
|
|
365
|
+
req.on("data", (chunk) => {
|
|
366
|
+
receivedBytes += chunk.length;
|
|
367
|
+
if (receivedBytes > MAX_FORM_BODY_BYTES) {
|
|
368
|
+
req.destroy();
|
|
369
|
+
reject(contentTooLarge({ detail: `The request body exceeded the maximum allowed size of ${MAX_FORM_BODY_BYTES} bytes.` }));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
chunks.push(chunk);
|
|
373
|
+
});
|
|
374
|
+
req.on("error", reject);
|
|
375
|
+
req.on("end", () => {
|
|
376
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
377
|
+
const params = new URLSearchParams(raw);
|
|
378
|
+
const out = {};
|
|
379
|
+
for (const [key, value] of params) out[key] = value;
|
|
380
|
+
resolve(out);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
function sendHtml(res, status, html) {
|
|
385
|
+
res.statusCode = status;
|
|
386
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
387
|
+
res.end(html);
|
|
388
|
+
}
|
|
389
|
+
function sendJson(res, status, body) {
|
|
390
|
+
res.statusCode = status;
|
|
391
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
392
|
+
res.end(JSON.stringify(body));
|
|
393
|
+
}
|
|
394
|
+
function redirectTo(res, location) {
|
|
395
|
+
res.statusCode = 302;
|
|
396
|
+
res.setHeader("Location", location);
|
|
397
|
+
res.end();
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Write a {@link ProblemDetailsError}'s standalone `application/problem+json` {@link Response}
|
|
401
|
+
* (`error.getResponse()`) onto a raw Node `ServerResponse`. This connect middleware has no Hono
|
|
402
|
+
* `Context` to hand the error to, so the Web `Response` it produces is adapted by hand here
|
|
403
|
+
* rather than reusing `problemDetailsErrorHandler` (`@adrianhall/cloudflare-toolkit/hono`).
|
|
404
|
+
*/
|
|
405
|
+
async function sendProblemDetails(res, error) {
|
|
406
|
+
const response = error.getResponse();
|
|
407
|
+
res.statusCode = response.status;
|
|
408
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
409
|
+
res.end(await response.text());
|
|
410
|
+
}
|
|
411
|
+
function redirectToLogin(res, loginPath, pathname) {
|
|
412
|
+
redirectTo(res, `${loginPath}?redirect=${encodeURIComponent(pathname)}`);
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
export { cloudflareAccessPlugin };
|
|
416
|
+
|
|
417
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/lib/vite/login-page.ts","../../src/lib/vite/plugin.ts"],"sourcesContent":["/**\n * @file The HTML template for the Vite dev-server login page rendered by `cloudflareAccessPlugin`\n * (./plugin.ts). A pure HTML-string builder with no dependency on `auth-internal` or any other\n * runtime module.\n */\n\n/** A selectable identity rendered on the dev login form. */\nexport interface DevLoginUser {\n /** Email address used as the JWT `email` claim. */\n email: string;\n /** Optional human-friendly display name. */\n name?: string;\n /**\n * Optional subject claim to pin for this identity.\n *\n * When provided it is used **verbatim** as the JWT `sub` so the identity has a stable,\n * realistic subject across logins. When omitted a random UUID is generated each time the user\n * signs in (matching the shape of a real Cloudflare Access `sub`).\n */\n sub?: string;\n}\n\n/**\n * Render a self-contained HTML page with a dev login form.\n *\n * @param loginPath - The path the form submits to (handled by the plugin).\n * @param redirectTo - The URL the user is returned to after login.\n * @param users - Optional selectable identities. When provided the form shows a radio list plus\n * a \"custom email\" option; when empty it falls back to a single email input.\n * @param error - Optional error message to display.\n * @returns A complete `<!DOCTYPE html>` document as a string.\n */\nexport function renderViteLoginPage(\n loginPath: string,\n redirectTo: string,\n users: DevLoginUser[] = [],\n error?: string\n): string {\n const errorHtml = error ? `<div class=\"error\" role=\"alert\">${escapeHtml(error)}</div>` : \"\";\n\n const hasUsers = users.length > 0;\n const usersHtml = hasUsers ? renderUserChoices(users) : renderEmailInput();\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Developer Login</title>\n <style>\n *,*::before,*::after{box-sizing:border-box}\n body{\n margin:0;font-family:system-ui,-apple-system,sans-serif;\n display:flex;align-items:center;justify-content:center;\n min-height:100vh;background:#f4f4f5;color:#18181b;\n }\n .card{\n background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);\n padding:2.5rem;width:100%;max-width:420px;\n }\n h1{margin:0 0 .25rem;font-size:1.5rem}\n p.subtitle{margin:0 0 1.5rem;color:#52525b;font-size:.875rem}\n fieldset{border:none;margin:0;padding:0}\n legend{font-size:.875rem;font-weight:500;margin-bottom:.5rem;padding:0}\n label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.375rem}\n .user-option{\n display:flex;align-items:center;gap:.625rem;\n padding:.625rem .75rem;border:1px solid #71717a;border-radius:8px;\n margin-bottom:.5rem;cursor:pointer;\n }\n .user-option:focus-within{border-color:#1d4ed8;box-shadow:0 0 0 3px rgba(29,78,216,.4)}\n .user-option input{margin:0}\n .user-option .meta{display:flex;flex-direction:column}\n .user-option .name{font-weight:500}\n .user-option .email{color:#52525b;font-size:.8125rem}\n input[type=\"email\"]{\n width:100%;padding:.625rem .75rem;border:1px solid #71717a;\n border-radius:8px;font-size:1rem;outline:none;\n transition:border-color .15s;\n }\n input[type=\"email\"]:focus{border-color:#1d4ed8;box-shadow:0 0 0 3px rgba(29,78,216,.4)}\n button{\n margin-top:1rem;width:100%;padding:.625rem;border:none;\n border-radius:8px;background:#1d4ed8;color:#fff;\n font-size:1rem;font-weight:500;cursor:pointer;\n transition:background .15s;\n }\n button:hover{background:#1e40af}\n .error{\n background:#fef2f2;color:#991b1b;border:1px solid #fecaca;\n padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.875rem;\n }\n .badge{\n display:inline-block;background:#fef3c7;color:#92400e;\n font-size:.75rem;font-weight:600;padding:.125rem .5rem;\n border-radius:9999px;margin-bottom:1rem;\n }\n </style>\n</head>\n<body>\n <main class=\"card\">\n <span class=\"badge\">LOCAL DEV</span>\n <h1>Developer Login</h1>\n <p class=\"subtitle\">Simulates Cloudflare Access in front of your Vite dev server.</p>\n ${errorHtml}\n <form method=\"POST\" action=\"${escapeHtml(loginPath)}\">\n <input type=\"hidden\" name=\"redirect\" value=\"${escapeHtml(redirectTo)}\" />\n ${usersHtml}\n <button type=\"submit\">Sign in</button>\n </form>\n </main>\n</body>\n</html>`;\n}\n\n// ---------------------------------------------------------------------------\n// Fragments\n// ---------------------------------------------------------------------------\n\n/** Single email input (no pre-configured users). */\nfunction renderEmailInput(): string {\n return `<label for=\"email\">Email address</label>\n <input\n id=\"email\"\n name=\"email\"\n type=\"email\"\n required\n autocomplete=\"email\"\n placeholder=\"you@example.com\"\n autofocus\n />`;\n}\n\n/** Radio list of selectable identities plus a custom-email field. */\nfunction renderUserChoices(users: DevLoginUser[]): string {\n const options = users\n .map((user, index) => {\n const id = `user-${index}`;\n const nameHtml =\n user.name ?\n `<span class=\"name\">${escapeHtml(user.name)}</span>`\n : `<span class=\"name\">${escapeHtml(user.email)}</span>`;\n const emailHtml = user.name ? `<span class=\"email\">${escapeHtml(user.email)}</span>` : \"\";\n const checked = index === 0 ? \" checked\" : \"\";\n return `<label class=\"user-option\" for=\"${id}\">\n <input type=\"radio\" id=\"${id}\" name=\"email\" value=\"${escapeHtml(user.email)}\"${checked} />\n <span class=\"meta\">${nameHtml}${emailHtml}</span>\n </label>`;\n })\n .join(\"\\n \");\n\n return `<fieldset>\n <legend>Choose an identity</legend>\n ${options}\n </fieldset>\n <label for=\"custom-email\">Or enter a custom email address</label>\n <input\n id=\"custom-email\"\n name=\"custom-email\"\n type=\"email\"\n autocomplete=\"email\"\n placeholder=\"you@example.com\"\n />`;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Minimal HTML-entity escaping for untrusted values injected into HTML. */\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n","/**\n * @file `cloudflareAccessPlugin` — a dev-only Vite plugin that emulates the Cloudflare Access\n * edge in front of `@cloudflare/vite-plugin`. In production, Cloudflare Access sits at the edge\n * and injects the `Cf-Access-Jwt-Assertion` header (and friends) into every request before it\n * reaches the Worker; during `vite dev` there is no Access in the loop. This plugin reproduces\n * that behavior at the Vite connect layer so the Worker can keep only the production\n * `cloudflareAccess` middleware (../hono/cloudflare-access.ts) — no separate dev-authentication\n * middleware, no `run_worker_first`.\n *\n * Built on this toolkit's own `auth-internal` module for the shared JWT/JWKS/policy primitives\n * — the same `matchPolicy`/`signDevJwt`/`verifyDevJwt`/`parseCookie`/`buildCookieHeader`/\n * `clearCookieHeader`/`DEFAULT_DEV_SECRET`/`JWT_HEADER`/`EMAIL_HEADER` that\n * `hono/cloudflare-access.ts` also consumes — so a session created here is accepted there\n * without any duplicated verification logic (proved end-to-end in\n * `test/node/vite/handshake.test.ts`).\n *\n * Also depends on `../errors/generators.js` (`contentTooLarge`) and\n * `../problem-details/error.js` (`ProblemDetailsError`) so an oversized login-form POST body\n * (see `readFormBody`) is rejected with a standard `application/problem+json` response, the same\n * shape every other error in this toolkit produces — CODE-008.\n */\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { Connect, Plugin } from \"vite\";\nimport {\n buildCookieHeader,\n clearCookieHeader,\n DEFAULT_DEV_SECRET,\n EMAIL_HEADER,\n JWT_HEADER,\n parseCookie,\n signDevJwt,\n verifyDevJwt\n} from \"../auth-internal/jwt.js\";\nimport { matchPolicy } from \"../auth-internal/policy.js\";\nimport type { PathPolicy } from \"../auth-internal/types.js\";\nimport { contentTooLarge } from \"../errors/generators.js\";\nimport { ProblemDetailsError } from \"../problem-details/error.js\";\nimport { renderViteLoginPage, type DevLoginUser } from \"./login-page.js\";\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_LOGIN_PATH = \"/cdn-cgi/access/login\";\nconst LOGOUT_PATH = \"/cdn-cgi/access/logout\";\nconst GET_IDENTITY_PATH = \"/cdn-cgi/access/get-identity\";\n\n/**\n * Path prefixes that belong to Vite's own internals (or asset requests) and must always pass\n * through untouched.\n */\nconst VITE_INTERNAL_PREFIXES = [\n \"/@vite\",\n \"/@fs\",\n \"/@id\",\n \"/@react-refresh\",\n \"/node_modules/\",\n \"/__vite\",\n \"/src/\"\n];\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/** Configuration for {@link cloudflareAccessPlugin}. */\nexport interface CloudflareAccessPluginOptions {\n /**\n * Path policies evaluated in order (first match wins).\n *\n * Pass the **same array** you give to `cloudflareAccess` in the Worker\n * (../hono/cloudflare-access.ts) so dev and prod agree on which paths are protected.\n *\n * - `authenticate: false` — public (no gating, no header injection).\n * - `authenticate: true` — protected. Unauthenticated navigations are redirected to the login\n * form; API routes with `redirect: false` receive a 401.\n *\n * When omitted, **all** non-internal paths are treated as protected.\n */\n policies?: PathPolicy[];\n\n /**\n * HMAC secret used to sign the dev JWT.\n *\n * Must match the `devSecret` passed to `cloudflareAccess` in the Worker (if overridden there).\n * Defaults to the same well-known development key.\n */\n devSecret?: string;\n\n /**\n * Selectable identities rendered on the dev login form. When omitted the form shows a single\n * free-text email input.\n */\n users?: DevLoginUser[];\n\n /** Pathname for the login form (default `\"/cdn-cgi/access/login\"`). */\n loginPath?: string;\n\n /** Dev JWT lifetime in seconds (default `86400` / 24 h). */\n tokenLifetime?: number;\n}\n\n// ---------------------------------------------------------------------------\n// Plugin factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create the dev-only Cloudflare Access emulation plugin.\n *\n * Register it **before** `@cloudflare/vite-plugin` (and any framework plugin) so its connect\n * middleware runs first:\n *\n * ```ts\n * plugins: [cloudflareAccessPlugin(), cloudflare(), react()]\n * ```\n *\n * The middleware is registered synchronously in the `configureServer` hook body (combined with\n * `enforce: \"pre\"`) so that it sits ahead of the request → `workerd` dispatch handler that\n * `@cloudflare/vite-plugin` registers from its post hook.\n *\n * @param options - Configuration for path policies, the dev secret, selectable login identities,\n * the login form path, and the dev token lifetime.\n * @returns A Vite `Plugin`.\n */\nexport function cloudflareAccessPlugin(options: CloudflareAccessPluginOptions = {}): Plugin {\n return {\n name: \"cloudflare-access-dev\",\n apply: \"serve\",\n enforce: \"pre\",\n configureServer(server) {\n server.middlewares.use(createAccessDevMiddleware(options));\n }\n };\n}\n\n// ---------------------------------------------------------------------------\n// Connect middleware\n// ---------------------------------------------------------------------------\n\n/**\n * Build the connect middleware that emulates Cloudflare Access.\n *\n * Exported separately so it can be unit-tested with mock `req`/`res` objects without booting a\n * real Vite server.\n *\n * @param options - Same options accepted by {@link cloudflareAccessPlugin}.\n * @returns A Vite `Connect.NextHandleFunction`.\n */\nexport function createAccessDevMiddleware(\n options: CloudflareAccessPluginOptions = {}\n): Connect.NextHandleFunction {\n const policies = options.policies;\n const devSecret = options.devSecret ?? DEFAULT_DEV_SECRET;\n const users = options.users ?? [];\n const loginPath = options.loginPath ?? DEFAULT_LOGIN_PATH;\n const tokenLifetime = options.tokenLifetime;\n\n return (req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction): void => {\n void handle(req, res, next).catch((err) => next(err as Error));\n };\n\n async function handle(\n req: IncomingMessage,\n res: ServerResponse,\n next: Connect.NextFunction\n ): Promise<void> {\n const pathname = getPathname(req);\n\n // 1. Vite internals / asset requests → always pass through.\n if (isViteInternal(pathname)) {\n return next();\n }\n\n // 2. Own the Cloudflare Access edge endpoints.\n if (pathname === loginPath) {\n if (req.method === \"POST\") {\n return handleLoginSubmit(req, res);\n }\n return serveLoginForm(req, res);\n }\n if (pathname === LOGOUT_PATH) {\n return handleLogout(res);\n }\n if (pathname === GET_IDENTITY_PATH) {\n return handleGetIdentity(req, res);\n }\n\n // 3. Authenticated session → inject Access headers and hand off.\n const token = parseCookie(req.headers.cookie);\n const verified = token ? await verifyDevJwt(token, devSecret) : null;\n if (token && verified) {\n injectAccessHeaders(req, token, verified.email);\n return next();\n }\n\n // 4. Unauthenticated. Decide based on policy + request type.\n const policyMatch = policies ? matchPolicy(pathname, policies) : undefined;\n\n // Explicitly public → pass through (no gating, no injection).\n if (policyMatch?.authenticate === false) {\n return next();\n }\n\n // API-style protected route (redirect: false) → 401 JSON.\n if (policyMatch?.authenticate === true && policyMatch.redirect === false) {\n return sendJson(res, 401, { error: \"Authentication required\" });\n }\n\n // HTML navigations to protected paths → redirect to the login form.\n if (isNavigation(req)) {\n return redirectToLogin(res, loginPath, pathname);\n }\n\n // Anything else (e.g. an unauthenticated fetch to a protected API that did not opt into\n // `redirect: false`) → let it through; the Worker's own cloudflareAccess() will reject it\n // with a 401.\n return next();\n }\n\n // -------------------------------------------------------------------------\n // Endpoint handlers\n // -------------------------------------------------------------------------\n\n function serveLoginForm(req: IncomingMessage, res: ServerResponse): void {\n const redirect = sanitizeRedirectTarget(getQueryParam(req, \"redirect\") ?? \"/\");\n sendHtml(res, 200, renderViteLoginPage(loginPath, redirect, users));\n }\n\n async function handleLoginSubmit(req: IncomingMessage, res: ServerResponse): Promise<void> {\n let body: Record<string, string>;\n try {\n body = await readFormBody(req);\n } catch (err) {\n if (err instanceof ProblemDetailsError) {\n await sendProblemDetails(res, err);\n return;\n }\n throw err;\n }\n const custom = typeof body[\"custom-email\"] === \"string\" ? body[\"custom-email\"].trim() : \"\";\n const selected = typeof body.email === \"string\" ? body.email.trim() : \"\";\n const email = custom || selected;\n const redirect = sanitizeRedirectTarget(\n typeof body.redirect === \"string\" && body.redirect ? body.redirect : \"/\"\n );\n\n if (!email) {\n sendHtml(\n res,\n 400,\n renderViteLoginPage(loginPath, redirect, users, \"A valid email address is required.\")\n );\n return;\n }\n\n // Pin the subject for a configured identity (stable, realistic sub); free-text / unknown\n // emails fall back to a generated UUID.\n const sub = users.find((u) => u.email === email)?.sub;\n const token = await signDevJwt(email, { secret: devSecret, lifetime: tokenLifetime, sub });\n res.setHeader(\"Set-Cookie\", buildCookieHeader(token, isSecure(req)));\n redirectTo(res, redirect);\n }\n\n function handleLogout(res: ServerResponse): void {\n res.setHeader(\"Set-Cookie\", clearCookieHeader());\n redirectTo(res, \"/\");\n }\n\n async function handleGetIdentity(req: IncomingMessage, res: ServerResponse): Promise<void> {\n const token = parseCookie(req.headers.cookie);\n const verified = token ? await verifyDevJwt(token, devSecret) : null;\n if (!verified) {\n sendJson(res, 401, { error: \"Authentication required\" });\n return;\n }\n const display = users.find((u) => u.email === verified.email)?.name;\n sendJson(res, 200, buildIdentity(verified.email, verified.sub, display));\n }\n\n // -------------------------------------------------------------------------\n // Header injection\n // -------------------------------------------------------------------------\n\n /**\n * Inject the Cloudflare Access headers so the request reaches the Worker authenticated.\n *\n * `@cloudflare/vite-plugin` builds the `Request` it dispatches into `workerd` from\n * `req.rawHeaders` (not the parsed `req.headers` object), so the JWT **must** be pushed onto\n * `rawHeaders`. We also mirror it onto `req.headers` so other connect middleware observe a\n * consistent view.\n */\n function injectAccessHeaders(req: IncomingMessage, token: string, email: string): void {\n req.rawHeaders.push(JWT_HEADER, token, EMAIL_HEADER, email);\n req.headers[JWT_HEADER] = token;\n req.headers[EMAIL_HEADER] = email;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Identity payload\n// ---------------------------------------------------------------------------\n\n/**\n * Build a Cloudflare-Access-shaped identity object for the `get-identity` endpoint. Mirrors the\n * real response closely enough for client code that reads `email`, `name`, `groups`, etc.\n *\n * @param email - Authenticated user's email.\n * @param sub - Authenticated user's subject identifier.\n * @param name - Optional display name; falls back to `email` when omitted.\n * @returns A Cloudflare-Access-shaped identity object.\n */\nfunction buildIdentity(email: string, sub: string, name?: string): Record<string, unknown> {\n return {\n id: sub,\n name: name ?? email,\n email,\n user_uuid: sub,\n account_id: \"dev-account\",\n iat: Math.floor(Date.now() / 1000),\n groups: [],\n idp: { id: \"dev\", type: \"dev-authentication\" },\n geo: { country: \"US\" },\n type: \"dev\"\n };\n}\n\n// ---------------------------------------------------------------------------\n// Request helpers\n// ---------------------------------------------------------------------------\n\nfunction getPathname(req: IncomingMessage): string {\n const raw = req.url ?? \"/\";\n const queryIndex = raw.indexOf(\"?\");\n return queryIndex === -1 ? raw : raw.slice(0, queryIndex);\n}\n\nfunction getQueryParam(req: IncomingMessage, key: string): string | undefined {\n const path = req.url ?? \"/\";\n const url = new URL(path, \"http://localhost\");\n return url.searchParams.get(key) ?? undefined;\n}\n\n/**\n * Restrict a client-supplied `redirect` target (the dev login form's query string / POST body\n * field) to a same-origin, root-relative path — SEC-005\n * (https://github.com/adrianhall/cloudflare-toolkit/issues/50).\n *\n * Only a value starting with a single `/` — not `//` or `/\\`, both of which a browser can\n * interpret as protocol-relative / scheme-relative and resolve against an attacker-controlled\n * host — is considered safe. Anything else (an absolute URL, a protocol-relative URL, or any\n * other unexpected value) falls back to `\"/\"`. Applied at both points a client-supplied\n * `redirect` value is read (the login form's query string and the login submission's POST body)\n * so the sanitized value is what flows into both the login form's hidden field and the actual\n * redirect `Location` header.\n */\nfunction sanitizeRedirectTarget(value: string): string {\n return /^\\/(?!\\/|\\\\)/.test(value) ? value : \"/\";\n}\n\nfunction isViteInternal(pathname: string): boolean {\n return VITE_INTERNAL_PREFIXES.some((prefix) => pathname.startsWith(prefix));\n}\n\n/**\n * A request is treated as an HTML navigation when the browser flags it as such\n * (`Sec-Fetch-Mode: navigate`) or it accepts `text/html`.\n */\nfunction isNavigation(req: IncomingMessage): boolean {\n if (req.method !== \"GET\" && req.method !== \"HEAD\") {\n return false;\n }\n const fetchMode = headerValue(req, \"sec-fetch-mode\");\n if (fetchMode) {\n return fetchMode === \"navigate\";\n }\n const accept = headerValue(req, \"accept\");\n return accept?.includes(\"text/html\") ?? false;\n}\n\nfunction isSecure(req: IncomingMessage): boolean {\n // The Vite dev server runs over plain HTTP on localhost; treat HTTPS forwarding hints as\n // secure so the cookie's Secure flag is correct.\n return headerValue(req, \"x-forwarded-proto\") === \"https\";\n}\n\nfunction headerValue(req: IncomingMessage, name: string): string | undefined {\n const value = req.headers[name];\n if (Array.isArray(value)) {\n return value[0];\n }\n return value ?? undefined;\n}\n\n/**\n * Maximum accumulated size, in bytes, accepted for the dev login form's\n * `application/x-www-form-urlencoded` POST body by {@link readFormBody}. The form only ever\n * posts a handful of small fields (`email`, `custom-email`, `redirect`), so 64 KiB is a generous\n * safety cap against an unbounded read — not a real-world limit — see CODE-008.\n */\nconst MAX_FORM_BODY_BYTES = 64 * 1024;\n\n/**\n * Read and parse an `application/x-www-form-urlencoded` request body, capped at\n * {@link MAX_FORM_BODY_BYTES}. Rejects with a `413 Content Too Large`\n * {@link ProblemDetailsError} (via `contentTooLarge`) once the cap is exceeded, after destroying\n * the underlying stream so no further data is buffered.\n */\nfunction readFormBody(req: IncomingMessage): Promise<Record<string, string>> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let receivedBytes = 0;\n req.on(\"data\", (chunk: Buffer) => {\n receivedBytes += chunk.length;\n if (receivedBytes > MAX_FORM_BODY_BYTES) {\n req.destroy();\n reject(\n contentTooLarge({\n detail: `The request body exceeded the maximum allowed size of ${MAX_FORM_BODY_BYTES} bytes.`\n })\n );\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"error\", reject);\n req.on(\"end\", () => {\n const raw = Buffer.concat(chunks).toString(\"utf8\");\n const params = new URLSearchParams(raw);\n const out: Record<string, string> = {};\n for (const [key, value] of params) {\n out[key] = value;\n }\n resolve(out);\n });\n });\n}\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction sendHtml(res: ServerResponse, status: number, html: string): void {\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"text/html; charset=utf-8\");\n res.end(html);\n}\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify(body));\n}\n\nfunction redirectTo(res: ServerResponse, location: string): void {\n res.statusCode = 302;\n res.setHeader(\"Location\", location);\n res.end();\n}\n\n/**\n * Write a {@link ProblemDetailsError}'s standalone `application/problem+json` {@link Response}\n * (`error.getResponse()`) onto a raw Node `ServerResponse`. This connect middleware has no Hono\n * `Context` to hand the error to, so the Web `Response` it produces is adapted by hand here\n * rather than reusing `problemDetailsErrorHandler` (`@adrianhall/cloudflare-toolkit/hono`).\n */\nasync function sendProblemDetails(res: ServerResponse, error: ProblemDetailsError): Promise<void> {\n const response = error.getResponse();\n res.statusCode = response.status;\n response.headers.forEach((value, key) => res.setHeader(key, value));\n res.end(await response.text());\n}\n\nfunction redirectToLogin(res: ServerResponse, loginPath: string, pathname: string): void {\n redirectTo(res, `${loginPath}?redirect=${encodeURIComponent(pathname)}`);\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adrianhall/cloudflare-toolkit",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A toolkit of utilities and skills for developing Workers on the Cloudflare Dev Platform",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cloudflare",
|
|
7
|
+
"cloudflare-workers",
|
|
8
|
+
"workers",
|
|
9
|
+
"wrangler",
|
|
10
|
+
"hono",
|
|
11
|
+
"vite",
|
|
12
|
+
"typescript",
|
|
13
|
+
"problem-details",
|
|
14
|
+
"logging",
|
|
15
|
+
"cloudflare-access"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://adrianhall.github.io/cloudflare-toolkit",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/adrianhall/cloudflare-toolkit/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/adrianhall/cloudflare-toolkit.git"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"private": false,
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"module": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"import": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./guards": {
|
|
40
|
+
"types": "./dist/guards/index.d.ts",
|
|
41
|
+
"import": "./dist/guards/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./errors": {
|
|
44
|
+
"types": "./dist/errors/index.d.ts",
|
|
45
|
+
"import": "./dist/errors/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./problem-details": {
|
|
48
|
+
"types": "./dist/problem-details/index.d.ts",
|
|
49
|
+
"import": "./dist/problem-details/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./logging": {
|
|
52
|
+
"types": "./dist/logging/index.d.ts",
|
|
53
|
+
"import": "./dist/logging/index.js"
|
|
54
|
+
},
|
|
55
|
+
"./hono": {
|
|
56
|
+
"types": "./dist/hono/index.d.ts",
|
|
57
|
+
"import": "./dist/hono/index.js"
|
|
58
|
+
},
|
|
59
|
+
"./vite": {
|
|
60
|
+
"types": "./dist/vite/index.d.ts",
|
|
61
|
+
"import": "./dist/vite/index.js"
|
|
62
|
+
},
|
|
63
|
+
"./testing": {
|
|
64
|
+
"types": "./dist/testing/index.d.ts",
|
|
65
|
+
"import": "./dist/testing/index.js"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"bin": {
|
|
69
|
+
"generate-wrangler-types": "./dist/cli/generate-wrangler-types/index.js"
|
|
70
|
+
},
|
|
71
|
+
"files": [
|
|
72
|
+
"dist",
|
|
73
|
+
"LICENSE",
|
|
74
|
+
"THIRD-PARTY-NOTICES.md"
|
|
75
|
+
],
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": ">=24",
|
|
78
|
+
"npm": ">=11"
|
|
79
|
+
},
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"hono": "^4.12.28",
|
|
82
|
+
"vite": "^8.1.4"
|
|
83
|
+
},
|
|
84
|
+
"peerDependenciesMeta": {
|
|
85
|
+
"vite": {
|
|
86
|
+
"optional": true
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"dependencies": {
|
|
90
|
+
"chalk": "^5.6.2",
|
|
91
|
+
"commander": "^15.0.0",
|
|
92
|
+
"cross-spawn": "^7.0.6",
|
|
93
|
+
"jose": "^6.2.3"
|
|
94
|
+
},
|
|
95
|
+
"devDependencies": {
|
|
96
|
+
"@cloudflare/vitest-pool-workers": "^0.18.3",
|
|
97
|
+
"@cloudflare/workers-types": "5.20260708.1",
|
|
98
|
+
"@types/cross-spawn": "^6.0.6",
|
|
99
|
+
"@types/node": "^26.1.1",
|
|
100
|
+
"@vitest/coverage-istanbul": "^4.1.10",
|
|
101
|
+
"eslint": "^10.6.0",
|
|
102
|
+
"eslint-plugin-jsdoc": "^63.0.12",
|
|
103
|
+
"hono": "^4.12.28",
|
|
104
|
+
"husky": "^9.1.7",
|
|
105
|
+
"npm-run-all2": "^9.0.2",
|
|
106
|
+
"prettier": "^3.9.5",
|
|
107
|
+
"shx": "^0.4.0",
|
|
108
|
+
"tsdown": "^0.22.4",
|
|
109
|
+
"typescript": "^6.0.3",
|
|
110
|
+
"typescript-eslint": "^8.63.0",
|
|
111
|
+
"vite": "^8.1.4",
|
|
112
|
+
"vitest": "^4.1.10",
|
|
113
|
+
"wrangler": "^4.109.0"
|
|
114
|
+
},
|
|
115
|
+
"scripts": {
|
|
116
|
+
"build": "tsdown",
|
|
117
|
+
"check": "run-s check:types check:lint check:format check:pack",
|
|
118
|
+
"check:format": "prettier --check .",
|
|
119
|
+
"check:lint": "eslint .",
|
|
120
|
+
"check:pack": "npm pack --ignore-scripts --dry-run",
|
|
121
|
+
"check:types": "tsc --noEmit",
|
|
122
|
+
"docs:build": "npm --prefix docs run build",
|
|
123
|
+
"docs:clean": "shx rm -rf docs/reference docs/.vitepress/cache docs/.vitepress/dist",
|
|
124
|
+
"docs:dev": "npm --prefix docs run dev",
|
|
125
|
+
"prepare": "husky",
|
|
126
|
+
"pretest": "npm run build",
|
|
127
|
+
"pretest:coverage": "npm run build",
|
|
128
|
+
"test": "vitest run",
|
|
129
|
+
"test:coverage": "vitest run --coverage"
|
|
130
|
+
},
|
|
131
|
+
"allowScripts": {
|
|
132
|
+
"esbuild@0.28.1": true,
|
|
133
|
+
"fsevents@2.3.3": true,
|
|
134
|
+
"sharp@0.34.5": true,
|
|
135
|
+
"workerd@1.20260708.1": true
|
|
136
|
+
}
|
|
137
|
+
}
|