@just-be/wildcard 0.5.0 → 0.7.0

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/package.json CHANGED
@@ -1,37 +1,37 @@
1
1
  {
2
2
  "name": "@just-be/wildcard",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Portable wildcard subdomain routing with pluggable storage backends",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "exports": {
8
- ".": "./src/index.ts",
9
- "./schemas": "./src/schemas.ts",
10
- "./handlers": "./src/handlers/index.ts",
11
- "./types": "./src/types.ts"
12
- },
13
- "files": [
14
- "src"
15
- ],
16
5
  "keywords": [
17
- "wildcard",
18
- "subdomain",
6
+ "proxy",
7
+ "redirect",
19
8
  "routing",
20
9
  "static-site",
21
- "redirect",
22
- "proxy"
10
+ "subdomain",
11
+ "wildcard"
23
12
  ],
24
- "author": "Justin Bennett",
25
13
  "license": "MIT",
14
+ "author": "Justin Bennett",
26
15
  "repository": {
27
16
  "type": "git",
28
17
  "url": "git+https://github.com/just-be-dev/just-be.dev.git",
29
18
  "directory": "packages/wildcard"
30
19
  },
31
- "peerDependencies": {
32
- "zod": "^4.0.0"
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "type": "module",
24
+ "main": "./src/index.ts",
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./schemas": "./src/schemas.ts",
28
+ "./handlers": "./src/handlers/index.ts",
29
+ "./types": "./src/types.ts"
33
30
  },
34
31
  "publishConfig": {
35
32
  "access": "public"
33
+ },
34
+ "peerDependencies": {
35
+ "zod": "^4.0.0"
36
36
  }
37
37
  }
@@ -7,7 +7,7 @@ import { matchPath, proxyRequest } from "../utils";
7
7
  */
8
8
  export async function handlePathRules(
9
9
  request: Request,
10
- config: StaticConfig
10
+ config: StaticConfig,
11
11
  ): Promise<Response | null> {
12
12
  const url = new URL(request.url);
13
13
  const pathname = url.pathname;
@@ -56,10 +56,10 @@ describe("handleRedirect", () => {
56
56
  };
57
57
  const response = await handleRedirect(
58
58
  makeRequest("https://git.just-be.dev/pds?tab=readme"),
59
- config
59
+ config,
60
60
  );
61
61
  expect(response.headers.get("location")).toBe(
62
- "https://github.com/just-be-dev/pds?tab=readme"
62
+ "https://github.com/just-be-dev/pds?tab=readme",
63
63
  );
64
64
  });
65
65
 
@@ -90,7 +90,7 @@ describe("handleRedirect", () => {
90
90
  };
91
91
  const response = await handleRedirect(
92
92
  makeRequest("https://sub.just-be.dev/some/path"),
93
- config
93
+ config,
94
94
  );
95
95
  expect(response.headers.get("location")).toBe("https://example.com");
96
96
  });
@@ -6,7 +6,7 @@ import { handlePathRules } from "./path-rules";
6
6
  export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = async (
7
7
  request,
8
8
  config,
9
- { fileLoader }
9
+ { fileLoader },
10
10
  ) => {
11
11
  // Check path-level redirects and rewrites first
12
12
  const pathResponse = await handlePathRules(request, config);
@@ -22,14 +22,15 @@ export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = a
22
22
  const basePath = subdomain;
23
23
 
24
24
  return spa
25
- ? handleSpaMode(fileLoader, basePath, url.pathname)
26
- : handleStaticMode(fileLoader, basePath, url.pathname, fallback);
25
+ ? handleSpaMode(fileLoader, basePath, url.pathname, request)
26
+ : handleStaticMode(fileLoader, basePath, url.pathname, request, fallback);
27
27
  };
28
28
 
29
29
  async function handleSpaMode(
30
30
  fileLoader: FileLoader,
31
31
  basePath: string,
32
- pathname: string
32
+ pathname: string,
33
+ request: Request,
33
34
  ): Promise<Response> {
34
35
  // Sanitize paths to prevent directory traversal
35
36
  const sanitizedBase = sanitizePath(basePath);
@@ -53,7 +54,7 @@ async function handleSpaMode(
53
54
  const fileObject = await fileLoader.loadFile(key);
54
55
 
55
56
  if (fileObject) {
56
- return buildFileResponse(fileObject, key);
57
+ return buildFileResponse(fileObject, key, request);
57
58
  }
58
59
  }
59
60
 
@@ -70,14 +71,15 @@ async function handleSpaMode(
70
71
  return new Response("File not found", { status: 404 });
71
72
  }
72
73
 
73
- return buildFileResponse(indexFile, indexKey);
74
+ return buildFileResponse(indexFile, indexKey, request);
74
75
  }
75
76
 
76
77
  async function handleStaticMode(
77
78
  fileLoader: FileLoader,
78
79
  basePath: string,
79
80
  pathname: string,
80
- fallback?: string
81
+ request: Request,
82
+ fallback?: string,
81
83
  ): Promise<Response> {
82
84
  // Sanitize paths to prevent directory traversal
83
85
  const sanitizedBase = sanitizePath(basePath);
@@ -99,7 +101,7 @@ async function handleStaticMode(
99
101
  const fileObject = await fileLoader.loadFile(key);
100
102
 
101
103
  if (fileObject) {
102
- return buildFileResponse(fileObject, key);
104
+ return buildFileResponse(fileObject, key, request);
103
105
  }
104
106
 
105
107
  // Try fallback if configured
@@ -113,14 +115,14 @@ async function handleStaticMode(
113
115
  const fallbackObject = await fileLoader.loadFile(fallbackKey);
114
116
 
115
117
  if (fallbackObject) {
116
- return buildFileResponse(fallbackObject, fallbackKey);
118
+ return buildFileResponse(fallbackObject, fallbackKey, request);
117
119
  }
118
120
  }
119
121
 
120
122
  return new Response("File not found", { status: 404 });
121
123
  }
122
124
 
123
- function buildFileResponse(fileObject: FileObject, key: string): Response {
125
+ function buildFileResponse(fileObject: FileObject, key: string, request: Request): Response {
124
126
  const headers = new Headers(fileObject.headers);
125
127
 
126
128
  if (fileObject.etag) {
@@ -143,13 +145,21 @@ function buildFileResponse(fileObject: FileObject, key: string): Response {
143
145
  if (!headers.has("Content-Security-Policy")) {
144
146
  headers.set(
145
147
  "Content-Security-Policy",
146
- "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
148
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';",
147
149
  );
148
150
  }
149
151
 
150
152
  // Cache control with Vary header for cache poisoning prevention
151
- headers.set("Cache-Control", "public, max-age=3600");
153
+ headers.set("Cache-Control", "public, max-age=3600"); // 1 hour browser cache
154
+ headers.set("CDN-Cache-Control", "public, max-age=604800"); // 7 days edge cache
152
155
  headers.set("Vary", "Accept-Encoding");
153
156
 
157
+ // Handle conditional requests (If-None-Match)
158
+ const ifNoneMatch = request.headers.get("If-None-Match");
159
+ if (ifNoneMatch && fileObject.etag && ifNoneMatch === fileObject.etag) {
160
+ // Return 304 Not Modified with headers but no body
161
+ return new Response(null, { status: 304, headers });
162
+ }
163
+
154
164
  return new Response(fileObject.body, { headers });
155
165
  }
package/src/schemas.ts CHANGED
@@ -37,6 +37,7 @@ export const StaticConfigSchema = z
37
37
  fallback: z.string().optional(), // Fallback file path for non-SPA mode (e.g., "404.html")
38
38
  redirects: z.array(PathRedirectSchema).optional(),
39
39
  rewrites: z.array(PathRewriteSchema).optional(),
40
+ password: z.string().optional(), // Name of the Worker secret containing the password for HTTP Basic Auth
40
41
  })
41
42
  .refine((data) => (data.spa && data.fallback ? false : true), {
42
43
  message: "fallback cannot be used with spa mode (spa: true)",
@@ -47,12 +48,14 @@ export const RedirectConfigSchema = z.object({
47
48
  url: safeUrl(),
48
49
  permanent: z.boolean().optional(),
49
50
  preservePath: z.boolean().optional(),
51
+ password: z.string().optional(),
50
52
  });
51
53
 
52
54
  export const RewriteConfigSchema = z.object({
53
55
  type: z.literal("rewrite"),
54
56
  url: safeUrl(),
55
57
  allowedMethods: z.array(HttpMethod).optional().default(["GET", "HEAD", "OPTIONS"]),
58
+ password: z.string().optional(),
56
59
  });
57
60
 
58
61
  export const RouteConfigSchema = z.discriminatedUnion("type", [
package/src/utils.test.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  filterSafeHeaders,
7
7
  isValidSubdomain,
8
8
  matchPath,
9
+ checkBasicAuth,
10
+ unauthorizedResponse,
9
11
  SAFE_REQUEST_HEADERS,
10
12
  } from "./utils";
11
13
 
@@ -343,6 +345,68 @@ describe("isValidSubdomain", () => {
343
345
  });
344
346
  });
345
347
 
348
+ describe("checkBasicAuth", () => {
349
+ function requestWithAuth(username: string, password: string): Request {
350
+ const encoded = btoa(`${username}:${password}`);
351
+ return new Request("https://example.com", {
352
+ headers: { Authorization: `Basic ${encoded}` },
353
+ });
354
+ }
355
+
356
+ it("should return true for correct password with any username", () => {
357
+ expect(checkBasicAuth(requestWithAuth("user", "secret"), "secret")).toBe(true);
358
+ expect(checkBasicAuth(requestWithAuth("admin", "secret"), "secret")).toBe(true);
359
+ expect(checkBasicAuth(requestWithAuth("", "secret"), "secret")).toBe(true);
360
+ });
361
+
362
+ it("should return false for wrong password", () => {
363
+ expect(checkBasicAuth(requestWithAuth("user", "wrong"), "secret")).toBe(false);
364
+ });
365
+
366
+ it("should return false when no Authorization header", () => {
367
+ const request = new Request("https://example.com");
368
+ expect(checkBasicAuth(request, "secret")).toBe(false);
369
+ });
370
+
371
+ it("should return false for non-Basic auth schemes", () => {
372
+ const request = new Request("https://example.com", {
373
+ headers: { Authorization: "Bearer token123" },
374
+ });
375
+ expect(checkBasicAuth(request, "secret")).toBe(false);
376
+ });
377
+
378
+ it("should return false for malformed base64", () => {
379
+ const request = new Request("https://example.com", {
380
+ headers: { Authorization: "Basic !!!invalid!!!" },
381
+ });
382
+ expect(checkBasicAuth(request, "secret")).toBe(false);
383
+ });
384
+
385
+ it("should return false for base64 without colon separator", () => {
386
+ const encoded = btoa("nocolon");
387
+ const request = new Request("https://example.com", {
388
+ headers: { Authorization: `Basic ${encoded}` },
389
+ });
390
+ expect(checkBasicAuth(request, "nocolon")).toBe(false);
391
+ });
392
+
393
+ it("should handle passwords containing colons", () => {
394
+ expect(checkBasicAuth(requestWithAuth("user", "pass:word:here"), "pass:word:here")).toBe(true);
395
+ });
396
+ });
397
+
398
+ describe("unauthorizedResponse", () => {
399
+ it("should return 401 status", () => {
400
+ const response = unauthorizedResponse();
401
+ expect(response.status).toBe(401);
402
+ });
403
+
404
+ it("should include WWW-Authenticate header", () => {
405
+ const response = unauthorizedResponse();
406
+ expect(response.headers.get("WWW-Authenticate")).toBe('Basic realm="Protected"');
407
+ });
408
+ });
409
+
346
410
  describe("matchPath", () => {
347
411
  describe("exact match patterns", () => {
348
412
  it("should match exact paths", () => {
package/src/utils.ts CHANGED
@@ -236,6 +236,40 @@ export function isValidSubdomain(subdomain: string): boolean {
236
236
  return true;
237
237
  }
238
238
 
239
+ /**
240
+ * Checks if a request has valid HTTP Basic Auth credentials
241
+ * Any username is accepted; only the password is validated.
242
+ */
243
+ export function checkBasicAuth(request: Request, expectedPassword: string): boolean {
244
+ const authorization = request.headers.get("Authorization");
245
+ if (!authorization || !authorization.startsWith("Basic ")) {
246
+ return false;
247
+ }
248
+
249
+ try {
250
+ const encoded = authorization.slice("Basic ".length);
251
+ const decoded = atob(encoded);
252
+ const colonIndex = decoded.indexOf(":");
253
+ if (colonIndex === -1) {
254
+ return false;
255
+ }
256
+ const password = decoded.slice(colonIndex + 1);
257
+ return password === expectedPassword;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Returns a 401 Unauthorized response that triggers the browser's Basic Auth dialog
265
+ */
266
+ export function unauthorizedResponse(): Response {
267
+ return new Response("Unauthorized", {
268
+ status: 401,
269
+ headers: { "WWW-Authenticate": 'Basic realm="Protected"' },
270
+ });
271
+ }
272
+
239
273
  /**
240
274
  * Timeout for proxy requests in milliseconds
241
275
  */
@@ -248,10 +282,7 @@ const FETCH_TIMEOUT_MS = 5_000;
248
282
  * @param targetUrl - The target URL to proxy to
249
283
  * @returns Response from the proxied request
250
284
  */
251
- export async function proxyRequest(
252
- request: Request,
253
- targetUrl: URL
254
- ): Promise<Response> {
285
+ export async function proxyRequest(request: Request, targetUrl: URL): Promise<Response> {
255
286
  // Filter headers to only include safe ones (prevents header injection)
256
287
  const safeHeaders = filterSafeHeaders(request.headers);
257
288