@poly-x/next 0.1.1 → 0.3.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,200 @@ 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();
28
+ ```
29
+
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"] });
23
37
  ```
24
38
 
25
- ## Server
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:
26
42
 
27
- Read the session in a Server Component or route handler (read-only — Server
28
- Components can't set cookies):
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
57
+
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
+
153
+ ## Cross-application sign-on and switching workspace
154
+
155
+ `createPolyXHandler()` also mounts `/api/polyx/hop` and `/api/polyx/hop-complete`. You do not
156
+ call these directly — `@poly-x/react`'s `useWarmHop()` and `switchWorkspace()` drive them.
157
+
158
+ **The attempt is made by the browser, and it has to be.** The platform recognises a returning
159
+ person from an ecosystem cookie set on *its own* domain. Your server does not have that cookie,
160
+ so a server-side attempt is told "sign-in required" every single time — correctly, and
161
+ indistinguishably from someone who genuinely is signed out. The feature would look implemented
162
+ and never once work.
163
+
164
+ So the routes bracket a browser-made request rather than making one:
165
+
166
+ - **`hop`** mints the PKCE pair, keeps the verifier in an httpOnly cookie, and returns only a
167
+ URL. It makes no request of its own.
168
+ - **`hop-complete`** takes the `code` the browser received and exchanges it here. The code is
169
+ safe for a page to hold precisely because the verifier that redeems it never left your server.
170
+
171
+ Because of that, calling `completeAuthorize()` from server code **throws**
172
+ (`WARM_HOP_ON_SERVER`) rather than returning the plausible answer.
173
+
174
+ Register your `/api/polyx/callback` on the App even though nothing redirects to it during a
175
+ hop — the platform validates `redirect_uri` on both the authorize and the exchange.
176
+
177
+ > **Warm hop needs https end to end** (or a `localhost` dev setup). The platform issues its
178
+ > ecosystem cookie with `Secure`, and a browser silently discards a `Secure` cookie delivered
179
+ > over http. Nothing reports this: sign-in still succeeds, the `Set-Cookie` header is still
180
+ > there, and every hop afterwards reports "sign-in required" — indistinguishable from someone
181
+ > who is genuinely signed out. `localhost` is the one host browsers exempt.
182
+
183
+ ### The boundary, stated plainly
184
+
185
+ **This works between applications that share the ecosystem cookie's domain.** Browsers block
186
+ third-party cookies by default, so an application on an unrelated domain cannot see that cookie
187
+ and will fall back to ordinary sign-in. That is a browser policy, not a limitation we can
188
+ configure away — solving it needs a different mechanism entirely. Nothing here claims otherwise.
189
+
190
+ Switching workspace is unaffected by that boundary: it happens inside one application.
191
+
55
192
  ## Exports
56
193
 
57
- `@poly-x/next` (client) · `@poly-x/next/server` (`auth`, `createAuthHandlers`,
58
- `ServerSession`) · `@poly-x/next/proxy` (`proxy`).
194
+ - `@poly-x/next` — the client surface (re-exports `@poly-x/react`): `PolyXProvider`,
195
+ `useAuth`, `useUser`, `useSession`, `useLiveAuthz`, `consumeAuthEvent`, `<SignIn>`,
196
+ `<UserButton>`, `<UserProfile>`, `<Protected>`.
197
+ - `@poly-x/next/server` — `createPolyXHandler`, `createDataProxy`, `auth`,
198
+ `getSessionContext`, `refreshSession`, `createAuthHandlers`, `ServerSession`,
199
+ `sealSession` / `openSession`, `SESSION_COOKIE_MAX_BYTES`, `resolvePublicOrigin`.
200
+ - `@poly-x/next/proxy` — `proxy`, `DEFAULT_SESSION_COOKIE`, `readSessionCookie`
201
+ (edge-safe: no server-only imports).
59
202
 
60
203
  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.3.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.3.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 };