@pylonsync/next 0.3.95 → 0.3.97

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/proxy.ts +85 -2
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.95",
6
+ "version": "0.3.97",
7
7
  "type": "module",
8
8
  "description": "Next.js helpers for Pylon — cookie-based auth gate, server-side session helpers, and reusable client hooks.",
9
9
  "exports": {
@@ -18,8 +18,8 @@
18
18
  "check": "tsc -p tsconfig.json --noEmit"
19
19
  },
20
20
  "dependencies": {
21
- "@pylonsync/sdk": "0.3.95",
22
- "@pylonsync/react": "0.3.95"
21
+ "@pylonsync/sdk": "0.3.97",
22
+ "@pylonsync/react": "0.3.97"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "next": ">=16.0.0",
package/src/proxy.ts CHANGED
@@ -18,8 +18,23 @@ export type CreatePylonProxyOptions = {
18
18
  */
19
19
  loginUrl?: string;
20
20
  /**
21
- * Routes the proxy applies to. Forms the `config.matcher` Next reads.
22
- * Defaults to `["/dashboard/:path*"]`.
21
+ * Routes the proxy applies to. Defaults to `["/dashboard/:path*"]`.
22
+ *
23
+ * The returned `config.matcher` is what Next.js statically extracts
24
+ * at build time to decide which requests run through this proxy.
25
+ * Pylon ALSO uses this list at runtime: if the consumer's outer
26
+ * `proxy.ts` exports a broader `config.matcher` than what was passed
27
+ * here (e.g. to also handle `/login` in their own logic), Pylon's
28
+ * proxy function falls through with `NextResponse.next()` for any
29
+ * request whose path doesn't match this option — so a consumer
30
+ * stacking proxies doesn't accidentally trigger Pylon's
31
+ * redirect-to-login on routes Pylon was never supposed to gate.
32
+ *
33
+ * Before 0.3.96 this option was only used to populate the returned
34
+ * `config.matcher`; the runtime function ignored it. Consumers with
35
+ * broader outer matchers hit ERR_TOO_MANY_REDIRECTS when an
36
+ * unauthenticated user landed on /login (Pylon redirected /login →
37
+ * /login?next=/login → /login → …).
23
38
  */
24
39
  matcher?: string[];
25
40
  };
@@ -38,6 +53,13 @@ export type CreatePylonProxyOptions = {
38
53
  * export { proxy, config };
39
54
  * ```
40
55
  *
56
+ * If you wrap Pylon's proxy in your own logic (handling e.g. signed-in
57
+ * users hitting `/login`), pass `matcher` so Pylon only gates the
58
+ * paths you actually want gated. Your outer `config.matcher` can be
59
+ * broader; Pylon's runtime function checks each request's path
60
+ * against the option it was constructed with and falls through
61
+ * (`NextResponse.next()`) for anything outside it.
62
+ *
41
63
  * The proxy only checks cookie *presence* — a forged value will fail the
42
64
  * server-side `/api/auth/me` revalidation in your layout. Its job is to
43
65
  * short-circuit the obvious "no session at all" case before any page
@@ -48,8 +70,30 @@ export function createPylonProxy(opts: CreatePylonProxyOptions = {}) {
48
70
  opts.cookieName ?? process.env.PYLON_COOKIE_NAME ?? "pylon_session";
49
71
  const loginUrl = opts.loginUrl ?? "/login";
50
72
  const matcher = opts.matcher ?? ["/dashboard/:path*"];
73
+ const matchers = matcher.map(compilePathMatcher);
51
74
 
52
75
  function proxy(request: NextRequest) {
76
+ const pathname = request.nextUrl.pathname;
77
+
78
+ // Self-loop guard. Without this, a no-session GET to `/login`
79
+ // itself (when the consumer's outer config.matcher includes
80
+ // `/login` — common when they want to handle "signed-in user
81
+ // hits /login" themselves) gets redirected to
82
+ // `/login?next=/login`, the browser follows, same proxy runs,
83
+ // infinite redirect → ERR_TOO_MANY_REDIRECTS in the browser.
84
+ // Surfaced 2026-05-15 by a pylon-cloud-style consumer with a
85
+ // composed proxy.
86
+ if (pathname === loginUrl) return NextResponse.next();
87
+
88
+ // Runtime matcher gate. The `matcher` option is now load-bearing
89
+ // (not just documentation for `config.matcher`) — requests
90
+ // outside it fall through. Lets consumers compose Pylon's
91
+ // proxy under a broader outer matcher without accidentally
92
+ // gating routes Pylon was never told about.
93
+ if (matchers.length > 0 && !matchers.some((m) => m(pathname))) {
94
+ return NextResponse.next();
95
+ }
96
+
53
97
  const session = request.cookies.get(cookieName);
54
98
  if (session) return NextResponse.next();
55
99
 
@@ -63,3 +107,42 @@ export function createPylonProxy(opts: CreatePylonProxyOptions = {}) {
63
107
 
64
108
  return { proxy, config: { matcher } };
65
109
  }
110
+
111
+ /**
112
+ * Compile a Next.js-style matcher pattern into a predicate that
113
+ * accepts a pathname and returns true on match. Covers the subset
114
+ * Pylon consumers actually use:
115
+ *
116
+ * - exact: "/login" → only "/login"
117
+ * - segment: "/api/:slug" → "/api/x", not "/api/x/y"
118
+ * - rest (greedy): "/dashboard/:path*" → "/dashboard" and any subpath
119
+ * - rest (>=1): "/api/:rest+" → "/api/x", "/api/x/y", NOT "/api"
120
+ *
121
+ * Anything more exotic (regex modifiers, optional groups, multiple
122
+ * params per segment) we punt on — Pylon's docs only show the
123
+ * patterns above, and a stricter parser would silently mis-match
124
+ * fancier strings instead of failing loudly.
125
+ */
126
+ function compilePathMatcher(pattern: string): (pathname: string) => boolean {
127
+ // Split into segments and translate each. Leading `/` is implicit.
128
+ const segs = pattern.split("/").filter(Boolean);
129
+ const parts: string[] = ["^"];
130
+ for (const seg of segs) {
131
+ if (seg === ":path*" || /^:\w+\*$/.test(seg)) {
132
+ // Zero-or-more trailing segments. Optional with leading slash.
133
+ parts.push("(?:/.*)?");
134
+ } else if (/^:\w+\+$/.test(seg)) {
135
+ // One-or-more trailing segments.
136
+ parts.push("/.+");
137
+ } else if (/^:\w+$/.test(seg)) {
138
+ // Single segment.
139
+ parts.push("/[^/]+");
140
+ } else {
141
+ // Literal — escape regex metacharacters.
142
+ parts.push("/" + seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
143
+ }
144
+ }
145
+ parts.push("/?$");
146
+ const re = new RegExp(parts.join(""));
147
+ return (pathname) => re.test(pathname);
148
+ }