@poly-x/next 0.1.1 → 0.2.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/README.md CHANGED
@@ -4,57 +4,161 @@ PolyX authentication for the Next.js App Router. Everything in `@poly-x/react`,
4
4
  plus a server side: tokens live **AES-GCM-encrypted in an httpOnly cookie on your
5
5
  own domain** — browser JavaScript never sees them.
6
6
 
7
- ## Install
7
+ ## Adopt it in three files
8
+
9
+ Install, set two environment values, add three files. None of them contains
10
+ security logic.
8
11
 
9
12
  ```bash
10
13
  npm install @poly-x/next
11
14
  ```
12
15
 
13
- Set two env vars: `NEXT_PUBLIC_POLYX_PUBLISHABLE_KEY` (the `pk_…` key) and
14
- `POLYX_SESSION_SECRET` (used to encrypt the session cookie).
16
+ ```bash
17
+ NEXT_PUBLIC_POLYX_PUBLISHABLE_KEY=pk_test_… # your app's publishable key
18
+ POLYX_SESSION_SECRET=… # encrypts the session cookie
19
+ ```
15
20
 
16
- ## Client
21
+ **1. Mount the routes.** One declaration mounts every action:
17
22
 
18
- The client entry re-exports `@poly-x/react` — `PolyXProvider`, hooks, `<SignIn>`,
19
- `<Protected>`, and friends:
23
+ ```ts
24
+ // app/api/polyx/[...polyx]/route.ts
25
+ import { createPolyXHandler } from "@poly-x/next/server";
20
26
 
21
- ```tsx
22
- import { PolyXProvider } from "@poly-x/next";
27
+ export const { GET, POST } = createPolyXHandler();
23
28
  ```
24
29
 
25
- ## Server
30
+ **2. Gate your routes** (Next 16 `proxy.ts`; `middleware.ts` on ≤15):
31
+
32
+ ```ts
33
+ // proxy.ts
34
+ import { proxy } from "@poly-x/next/proxy";
35
+
36
+ export default proxy({ protectedRoutes: ["/dashboard"] });
37
+ ```
38
+
39
+ **3. Declare each backend you call.** Because the browser holds no token, calls to
40
+ your own API are proxied server-side — you declare the upstream, the SDK attaches
41
+ the credential, refreshes it on expiry and retries once:
42
+
43
+ ```ts
44
+ // app/api/billing/proxy/[...path]/route.ts
45
+ import { createDataProxy } from "@poly-x/next/server";
46
+
47
+ export const { GET, POST, PUT, PATCH, DELETE } = createDataProxy({
48
+ upstream: process.env.BILLING_URL!,
49
+ label: "Billing",
50
+ });
51
+ ```
52
+
53
+ Point your existing client at `/api/billing/proxy` instead of the upstream. No
54
+ call sites change beyond the base URL.
55
+
56
+ ## Reading the session
26
57
 
27
- Read the session in a Server Component or route handler (read-only Server
28
- Components can't set cookies):
58
+ **Server** `auth()` is read-only, so it is safe in a Server Component:
29
59
 
30
60
  ```ts
31
61
  import { auth } from "@poly-x/next/server";
32
62
 
33
- export default async function Page() {
63
+ export async function currentUserId(): Promise<string | null> {
34
64
  const { isSignedIn, userId } = await auth();
35
- //
65
+ return isSignedIn ? userId : null;
36
66
  }
37
67
  ```
38
68
 
39
- Mount the route handlers that own every cookie write (login / callback / refresh
40
- / signout):
69
+ For the full profile from the platform, use `getSessionContext()`. It distinguishes
70
+ "the directory is briefly unavailable" from "signed out", so a transient outage
71
+ never signs your users out:
41
72
 
42
73
  ```ts
43
- // app/api/polyx/[...action]/route.ts
44
- import { createAuthHandlers } from "@poly-x/next/server";
45
- export const { signin: GET } = createAuthHandlers();
74
+ import { getSessionContext } from "@poly-x/next/server";
75
+
76
+ export async function loadProfile() {
77
+ const ctx = await getSessionContext();
78
+ if (ctx.isSignedIn && ctx.profileStatus === "unavailable") {
79
+ // still signed in — the profile just could not be loaded right now
80
+ return null;
81
+ }
82
+ return ctx.user;
83
+ }
84
+ ```
85
+
86
+ **Client** — the hooks work with no provider under this custody model:
87
+
88
+ ```tsx
89
+ "use client";
90
+ import { useAuth, useUser } from "@poly-x/next";
91
+
92
+ export function Profile() {
93
+ const { isLoaded, isSignedIn, signOut, refresh } = useAuth();
94
+ const user = useUser();
95
+ if (!isLoaded) return null;
96
+ if (!isSignedIn) return <a href="/sign-in">Sign in</a>;
97
+ return (
98
+ <div>
99
+ <span>{user?.email}</span>
100
+ <button onClick={() => void refresh()}>Re-check permissions</button>
101
+ <button onClick={() => void signOut({ redirectTo: "/sign-in" })}>Sign out</button>
102
+ </div>
103
+ );
104
+ }
46
105
  ```
47
106
 
48
- Gate routes with the proxy (Next 16 `proxy.ts`; `middleware.ts` on ≤15):
107
+ `signOut()` performs the full-page navigation this custody model requires — a
108
+ client-side redirect would leave the httpOnly cookie in place and your route gate
109
+ would bounce the user straight back in. Do not hand-roll it.
110
+
111
+ `refresh()` re-reads authorization and resolves `true` only when the claims
112
+ actually changed; pair it with `useLiveAuthz` to re-guard a tab without a reload.
113
+
114
+ `consumeAuthEvent()` returns `"signed-in" | "signed-out" | null` once after the
115
+ navigation, for a toast or an analytics call that would otherwise be destroyed
116
+ when the page unloads.
117
+
118
+ ## Styles
119
+
120
+ If you render `<SignIn>` or `<UserButton>`, import the stylesheet **before** your
121
+ Tailwind entry. Cascade-layer order is fixed at first declaration, so importing it
122
+ later silently loses to your utilities:
49
123
 
50
124
  ```ts
51
- import { proxy } from "@poly-x/next/proxy";
52
- export const proxy = proxyHandler; // from proxy({ protectedRoutes: ["/dashboard"] })
125
+ // app/layout.tsx
126
+ import "@poly-x/next/styles.css";
127
+ import "./globals.css";
53
128
  ```
54
129
 
130
+ ## When something goes wrong
131
+
132
+ | Symptom | Cause |
133
+ |---|---|
134
+ | `401` from a proxied call, no upstream request made | No session, or the cookie would not unseal (rotated `POLYX_SESSION_SECRET`). |
135
+ | `401` from a proxied call to a path you did not declare | Only declared upstreams receive a credential. |
136
+ | `{ status: "configuration_error", code: "REDIRECT_URI_NOT_ALLOWED" }` | The redirect URI the SDK sent is not registered. Behind a proxy set `POLYX_PUBLIC_ORIGIN`, or enable `POLYX_TRUST_FORWARDED_ORIGIN` — `request.url` is the bind address. The server log names the URI actually used. |
137
+ | `{ status: "session_too_large" }` | The sealed session exceeds the ~4096-byte browser cookie limit. Usually a tenant configured to embed full permissions in the JWT. |
138
+ | Sign-in returns 200 but the user is never signed in | Upgrade to ≥ 0.1.1 — earlier versions wrote an oversized cookie the browser silently discarded. |
139
+
140
+ ## Profile self-service
141
+
142
+ `createPolyXHandler()` also mounts `/api/polyx/profile`, which backs
143
+ `<UserProfile>` — nothing extra to wire. It reads and updates **only** the
144
+ signed-in person's own profile: the identity comes from the sealed session, never
145
+ from the request, so it cannot be aimed at anyone else.
146
+
147
+ The submitted body is rebuilt from an allow-list rather than forwarded. The
148
+ platform's update applies whatever recognised fields it receives — including
149
+ `email`, `password` and `role` — so relaying a browser's multipart verbatim would
150
+ turn a profile form into a way to change a credential or grant a role. Only the
151
+ six profile fields ever leave the server.
152
+
55
153
  ## Exports
56
154
 
57
- `@poly-x/next` (client) · `@poly-x/next/server` (`auth`, `createAuthHandlers`,
58
- `ServerSession`) · `@poly-x/next/proxy` (`proxy`).
155
+ - `@poly-x/next` — the client surface (re-exports `@poly-x/react`): `PolyXProvider`,
156
+ `useAuth`, `useUser`, `useSession`, `useLiveAuthz`, `consumeAuthEvent`, `<SignIn>`,
157
+ `<UserButton>`, `<UserProfile>`, `<Protected>`.
158
+ - `@poly-x/next/server` — `createPolyXHandler`, `createDataProxy`, `auth`,
159
+ `getSessionContext`, `refreshSession`, `createAuthHandlers`, `ServerSession`,
160
+ `sealSession` / `openSession`, `SESSION_COOKIE_MAX_BYTES`, `resolvePublicOrigin`.
161
+ - `@poly-x/next/proxy` — `proxy`, `DEFAULT_SESSION_COOKIE`, `readSessionCookie`
162
+ (edge-safe: no server-only imports).
59
163
 
60
164
  MIT licensed.
package/dist/index.cjs CHANGED
@@ -2,7 +2,8 @@
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  //#region src/index.ts
4
4
  const PACKAGE_NAME = "@poly-x/next";
5
- const version = "0.0.0";
5
+ /** The published version of this package, injected at build time. */
6
+ const version = "0.2.0";
6
7
  //#endregion
7
8
  exports.PACKAGE_NAME = PACKAGE_NAME;
8
9
  exports.version = version;
package/dist/index.d.cts CHANGED
@@ -2,6 +2,7 @@ export * from "@poly-x/react";
2
2
 
3
3
  //#region src/index.d.ts
4
4
  declare const PACKAGE_NAME = "@poly-x/next";
5
- declare const version = "0.0.0";
5
+ /** The published version of this package, injected at build time. */
6
+ declare const version: string;
6
7
  //#endregion
7
8
  export { PACKAGE_NAME, version };
package/dist/index.d.mts CHANGED
@@ -2,6 +2,7 @@ export * from "@poly-x/react";
2
2
 
3
3
  //#region src/index.d.ts
4
4
  declare const PACKAGE_NAME = "@poly-x/next";
5
- declare const version = "0.0.0";
5
+ /** The published version of this package, injected at build time. */
6
+ declare const version: string;
6
7
  //#endregion
7
8
  export { PACKAGE_NAME, version };
package/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from "@poly-x/react";
3
3
  //#region src/index.ts
4
4
  const PACKAGE_NAME = "@poly-x/next";
5
- const version = "0.0.0";
5
+ /** The published version of this package, injected at build time. */
6
+ const version = "0.2.0";
6
7
  //#endregion
7
8
  export { PACKAGE_NAME, version };
package/dist/proxy.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- require("./server-session-Df9HXl_5.cjs");
2
+ const require_session_cookie = require("./session-cookie-BQD10B3D.cjs");
3
3
  let next_server = require("next/server");
4
4
  //#region src/proxy.ts
5
5
  /**
@@ -8,17 +8,37 @@ let next_server = require("next/server");
8
8
  * gates on the session cookie's *presence* (a fast edge check) and redirects
9
9
  * signed-out users to sign-in; `auth()` does the real decrypt server-side.
10
10
  */
11
- function isProtected(pathname, routes) {
12
- return routes.some((route) => typeof route === "string" ? pathname.startsWith(route) : route.test(pathname));
11
+ /**
12
+ * Make a matcher that gives the same answer every time it is called.
13
+ *
14
+ * `RegExp.prototype.test` advances `lastIndex` when the pattern carries `g` or `y`, so testing the
15
+ * SAME regex object repeatedly alternates true/false/true. This middleware holds the consumer's
16
+ * patterns for the lifetime of the process and tests them once per request — so a consumer writing
17
+ * `/^\/admin/g`, an easy habit, would have their protected routes gated on every OTHER request.
18
+ * That fails **open**, intermittently, and is close to undebuggable from the outside.
19
+ *
20
+ * The stateful flags are stripped into a fresh regex here, once at construction: no per-request
21
+ * allocation, and the consumer's own object is left untouched (they may be using it elsewhere, and
22
+ * resetting its `lastIndex` behind their back would be its own surprise).
23
+ */
24
+ function toMatcher(route) {
25
+ if (typeof route === "string") return (pathname) => pathname.startsWith(route);
26
+ if (!route.global && !route.sticky) return (pathname) => route.test(pathname);
27
+ const stateless = new RegExp(route.source, route.flags.replace(/[gy]/g, ""));
28
+ return (pathname) => stateless.test(pathname);
13
29
  }
14
30
  function proxy(config) {
15
31
  const cookieName = config.cookieName ?? "polyx_session";
16
32
  const signInPath = config.signInPath ?? "/api/polyx/signin";
33
+ const matchers = config.protectedRoutes.map(toMatcher);
34
+ const isProtected = (pathname) => matchers.some((match) => match(pathname));
17
35
  return (request) => {
18
- if (!isProtected(request.nextUrl.pathname, config.protectedRoutes)) return next_server.NextResponse.next();
36
+ if (!isProtected(request.nextUrl.pathname)) return next_server.NextResponse.next();
19
37
  if (request.cookies.get(cookieName)?.value) return next_server.NextResponse.next();
20
38
  return next_server.NextResponse.redirect(new URL(signInPath, request.url));
21
39
  };
22
40
  }
23
41
  //#endregion
42
+ exports.DEFAULT_SESSION_COOKIE = require_session_cookie.DEFAULT_SESSION_COOKIE;
24
43
  exports.proxy = proxy;
44
+ exports.readSessionCookie = require_session_cookie.readSessionCookie;
package/dist/proxy.d.cts CHANGED
@@ -1,3 +1,4 @@
1
+ import { n as readSessionCookie, t as DEFAULT_SESSION_COOKIE } from "./session-cookie-AEnY4PVW.cjs";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
 
3
4
  //#region src/proxy.d.ts
@@ -10,4 +11,4 @@ interface ProxyConfig {
10
11
  }
11
12
  declare function proxy(config: ProxyConfig): (request: NextRequest) => NextResponse;
12
13
  //#endregion
13
- export { ProxyConfig, proxy };
14
+ export { DEFAULT_SESSION_COOKIE, ProxyConfig, proxy, readSessionCookie };
package/dist/proxy.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { n as readSessionCookie, t as DEFAULT_SESSION_COOKIE } from "./session-cookie-AEnY4PVW.mjs";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
 
3
4
  //#region src/proxy.d.ts
@@ -10,4 +11,4 @@ interface ProxyConfig {
10
11
  }
11
12
  declare function proxy(config: ProxyConfig): (request: NextRequest) => NextResponse;
12
13
  //#endregion
13
- export { ProxyConfig, proxy };
14
+ export { DEFAULT_SESSION_COOKIE, ProxyConfig, proxy, readSessionCookie };
package/dist/proxy.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import "./server-session-pY-nzh5x.mjs";
1
+ import { n as readSessionCookie, t as DEFAULT_SESSION_COOKIE } from "./session-cookie-C23WspJZ.mjs";
2
2
  import { NextResponse } from "next/server";
3
3
  //#region src/proxy.ts
4
4
  /**
@@ -7,17 +7,35 @@ import { NextResponse } from "next/server";
7
7
  * gates on the session cookie's *presence* (a fast edge check) and redirects
8
8
  * signed-out users to sign-in; `auth()` does the real decrypt server-side.
9
9
  */
10
- function isProtected(pathname, routes) {
11
- return routes.some((route) => typeof route === "string" ? pathname.startsWith(route) : route.test(pathname));
10
+ /**
11
+ * Make a matcher that gives the same answer every time it is called.
12
+ *
13
+ * `RegExp.prototype.test` advances `lastIndex` when the pattern carries `g` or `y`, so testing the
14
+ * SAME regex object repeatedly alternates true/false/true. This middleware holds the consumer's
15
+ * patterns for the lifetime of the process and tests them once per request — so a consumer writing
16
+ * `/^\/admin/g`, an easy habit, would have their protected routes gated on every OTHER request.
17
+ * That fails **open**, intermittently, and is close to undebuggable from the outside.
18
+ *
19
+ * The stateful flags are stripped into a fresh regex here, once at construction: no per-request
20
+ * allocation, and the consumer's own object is left untouched (they may be using it elsewhere, and
21
+ * resetting its `lastIndex` behind their back would be its own surprise).
22
+ */
23
+ function toMatcher(route) {
24
+ if (typeof route === "string") return (pathname) => pathname.startsWith(route);
25
+ if (!route.global && !route.sticky) return (pathname) => route.test(pathname);
26
+ const stateless = new RegExp(route.source, route.flags.replace(/[gy]/g, ""));
27
+ return (pathname) => stateless.test(pathname);
12
28
  }
13
29
  function proxy(config) {
14
30
  const cookieName = config.cookieName ?? "polyx_session";
15
31
  const signInPath = config.signInPath ?? "/api/polyx/signin";
32
+ const matchers = config.protectedRoutes.map(toMatcher);
33
+ const isProtected = (pathname) => matchers.some((match) => match(pathname));
16
34
  return (request) => {
17
- if (!isProtected(request.nextUrl.pathname, config.protectedRoutes)) return NextResponse.next();
35
+ if (!isProtected(request.nextUrl.pathname)) return NextResponse.next();
18
36
  if (request.cookies.get(cookieName)?.value) return NextResponse.next();
19
37
  return NextResponse.redirect(new URL(signInPath, request.url));
20
38
  };
21
39
  }
22
40
  //#endregion
23
- export { proxy };
41
+ export { DEFAULT_SESSION_COOKIE, proxy, readSessionCookie };