@pylonsync/next 0.2.11 → 0.2.12

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 ADDED
@@ -0,0 +1,184 @@
1
+ # @pylonsync/next
2
+
3
+ Next.js 16 helpers for Pylon. Cookie-based auth, server-side data
4
+ loading, edge proxy gate, OAuth provider rendering — all designed
5
+ around App Router conventions.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @pylonsync/next
11
+ # or: npm i @pylonsync/next
12
+ ```
13
+
14
+ ## Setup
15
+
16
+ ### 1. Wire the API origin
17
+
18
+ Set `PYLON_TARGET` on your Next host (Vercel project, fly.toml, etc.)
19
+ to your Pylon control-plane origin.
20
+
21
+ ```env
22
+ PYLON_TARGET=https://api.example.com
23
+ ```
24
+
25
+ In dev `next dev` defaults to `http://localhost:4321` (the `pylon dev`
26
+ default port) so no env tweaking required.
27
+
28
+ ### 2. Build a server helper
29
+
30
+ ```ts
31
+ // src/lib/pylon.ts
32
+ import { createPylonServer } from "@pylonsync/next/server";
33
+
34
+ export const pylon = createPylonServer({
35
+ // Pylon emits `${app_name}_session` — pass that exact name. There's
36
+ // no "default" because the package can't know your app name; passing
37
+ // it explicitly here also kills a class of silent-breakage bugs
38
+ // where a wrong env var quietly breaks auth in production.
39
+ cookieName: "myapp_session",
40
+ // Optional: name of your "current user" function. Default "getMe".
41
+ getMeFn: "getMe",
42
+ });
43
+ ```
44
+
45
+ ### 3. Define the `getMe` function on Pylon
46
+
47
+ ```ts
48
+ // apps/control-plane/functions/getMe.ts
49
+ import { query } from "@pylonsync/functions";
50
+
51
+ export default query({
52
+ args: {},
53
+ async handler(ctx) {
54
+ if (!ctx.auth.userId) return null;
55
+ const user = await ctx.db.get("User", ctx.auth.userId);
56
+ if (!user) return null;
57
+ // Project to safe-to-display fields. Bypasses the User entity's
58
+ // read policy (which typically denies everything to keep the
59
+ // password hash from leaving the server).
60
+ return {
61
+ id: user.id,
62
+ email: user.email,
63
+ displayName: user.displayName,
64
+ };
65
+ },
66
+ });
67
+ ```
68
+
69
+ ### 4. Gate dashboard routes with a proxy
70
+
71
+ ```ts
72
+ // src/proxy.ts
73
+ import { createPylonProxy } from "@pylonsync/next/proxy";
74
+
75
+ // Next 16 statically extracts `config.matcher` at build time — it has
76
+ // to be an inline literal. We pass the same array to createPylonProxy
77
+ // so the runtime matches; tsc catches drift.
78
+ export const config = {
79
+ matcher: ["/dashboard/:path*", "/onboarding/:path*"],
80
+ };
81
+
82
+ export const proxy = createPylonProxy({
83
+ cookieName: "myapp_session",
84
+ matcher: ["/dashboard/:path*", "/onboarding/:path*"],
85
+ }).proxy;
86
+ ```
87
+
88
+ ### 5. Use it in pages
89
+
90
+ ```tsx
91
+ // src/app/dashboard/layout.tsx
92
+ import { pylon } from "@/lib/pylon";
93
+ import type { User } from "@/lib/types";
94
+
95
+ export default async function DashboardLayout({ children }) {
96
+ const me = await pylon.requireMe<User>();
97
+ return <Chrome user={me.user}>{children}</Chrome>;
98
+ }
99
+ ```
100
+
101
+ ```tsx
102
+ // src/app/dashboard/page.tsx
103
+ import { redirect } from "next/navigation";
104
+ import { pylon } from "@/lib/pylon";
105
+
106
+ type Org = { id: string; name: string; slug: string };
107
+
108
+ export default async function DashboardPage() {
109
+ const orgs = await pylon.json<Org[]>("/api/entities/Organization");
110
+ if (orgs.length === 0) redirect("/onboarding");
111
+ return <OrgsList orgs={orgs} />;
112
+ }
113
+ ```
114
+
115
+ ```tsx
116
+ // src/app/login/page.tsx — no client-mount flicker for OAuth row
117
+ import { pylon } from "@/lib/pylon";
118
+ import { LoginForm } from "./login-form"; // "use client"
119
+
120
+ export default async function LoginPage() {
121
+ const providers = await pylon.getOAuthProviders();
122
+ return <LoginForm providers={providers} />;
123
+ }
124
+ ```
125
+
126
+ ## Server helper API
127
+
128
+ `createPylonServer(config)` returns a `PylonServer` with:
129
+
130
+ | Method | Returns | Notes |
131
+ |---|---|---|
132
+ | `fetch(path, init?)` | `Response` | Forwards the session cookie. Caller handles status. |
133
+ | `json<T>(path, init?)` | `T` | Parses + status-checks. Throws `ApiError` on non-2xx. |
134
+ | `getAuth()` | `PylonAuth \| null` | userId, tenantId, isAdmin, cookieHeader. |
135
+ | `requireAuth()` | `PylonAuth` | Redirects to `loginUrl` on null. |
136
+ | `getMe<U>()` | `{auth, user: U} \| null` | Calls `/api/fn/${getMeFn}`. |
137
+ | `requireMe<U>()` | `{auth, user: U}` | Redirects on null. |
138
+ | `getOAuthProviders()` | `OAuthProvider[]` | Empty array on failure. |
139
+
140
+ ## Client helpers
141
+
142
+ ```ts
143
+ // src/lib/api.ts
144
+ import { createPylonClient } from "@pylonsync/next/client";
145
+ export const api = createPylonClient(); // same-origin via Next rewrite
146
+
147
+ // usage from a client component
148
+ const me = await api<Me>("/api/auth/me");
149
+ ```
150
+
151
+ ```tsx
152
+ // "use client" form
153
+ import { useAuthSubmit, loginWithPassword } from "@pylonsync/next/auth";
154
+
155
+ const { submit, error, busy } = useAuthSubmit(loginWithPassword);
156
+ ```
157
+
158
+ `@pylonsync/next/auth` provides: `signupWithPassword`, `loginWithPassword`,
159
+ `logout`, `startOAuthLogin`, `verifyEmail`, `useOAuthProviders`,
160
+ `useAuthSubmit`.
161
+
162
+ ## CORS / cookies across subdomains
163
+
164
+ Most apps deploy the dashboard at `app.example.com` and the Pylon
165
+ control plane at `api.example.com`. To make the session cookie visible
166
+ on both:
167
+
168
+ ```sh
169
+ fly secrets set -a my-pylon-app \
170
+ PYLON_DASHBOARD_URL=https://app.example.com \
171
+ PYLON_COOKIE_DOMAIN=.example.com \
172
+ PYLON_CORS_ORIGIN=https://app.example.com
173
+ ```
174
+
175
+ The dashboard's `next.config.ts` should rewrite `/api/*` to the API
176
+ origin so the browser sees same-origin (no CORS preflights). The
177
+ package's server-side helpers hit `PYLON_TARGET` directly and skip
178
+ the rewrite.
179
+
180
+ ## Versioning
181
+
182
+ Tracks the rest of `@pylonsync/*`. Pylon binary 0.2.x → package 0.2.x.
183
+
184
+ License: MIT OR Apache-2.0.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.11",
6
+ "version": "0.2.12",
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": {
package/src/client.ts CHANGED
@@ -1,19 +1,8 @@
1
1
  "use client";
2
2
 
3
- /**
4
- * Thrown by the API client on any non-2xx response. Carries the wire
5
- * `code` (e.g. `OAUTH_INVALID_STATE`) so UI can branch on specific
6
- * failures instead of string-matching the message.
7
- */
8
- export class ApiError extends Error {
9
- constructor(
10
- public status: number,
11
- public code: string,
12
- message: string,
13
- ) {
14
- super(message);
15
- }
16
- }
3
+ import { ApiError } from "./errors";
4
+
5
+ export { ApiError };
17
6
 
18
7
  /**
19
8
  * Options for {@link createPylonClient}.
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared error type for Pylon API calls. Lives outside `client.ts` (which
3
+ * is `"use client"`) so server code can throw + catch the same class
4
+ * without dragging the whole client bundle in.
5
+ *
6
+ * Carries the wire `code` (e.g. `OAUTH_INVALID_STATE`) so UI can branch
7
+ * on specific failures instead of string-matching the message.
8
+ */
9
+ export class ApiError extends Error {
10
+ constructor(
11
+ public status: number,
12
+ public code: string,
13
+ message: string,
14
+ ) {
15
+ super(message);
16
+ this.name = "ApiError";
17
+ }
18
+ }
package/src/index.ts CHANGED
@@ -3,5 +3,5 @@
3
3
  // don't accidentally land in client bundles; client-only hooks
4
4
  // live at `@pylonsync/next/auth` for the same reason.
5
5
 
6
+ export { ApiError } from "./errors";
6
7
  export type { OAuthProvider, Session } from "./auth";
7
- export { ApiError } from "./client";
package/src/server.ts CHANGED
@@ -1,22 +1,12 @@
1
1
  import { cookies } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
3
  import type { OAuthProvider } from "./auth";
4
+ import { ApiError } from "./errors";
4
5
 
5
6
  /**
6
- * Resolved Pylon session for a server-side request. Use `cookieHeader`
7
- * when forwarding the cookie to subsequent Pylon API calls (see
8
- * {@link pylonFetch}).
9
- */
10
- export type PylonSession = {
11
- userId: string;
12
- cookieHeader: string;
13
- };
14
-
15
- /**
16
- * Full auth shape from `/api/auth/me`. Superset of {@link PylonSession}
17
- * — adds the active tenant and admin flag. Use when the server-rendered
18
- * UI needs to branch on tenant or role (e.g. show org switcher only
19
- * when isAdmin, scope queries to tenantId).
7
+ * Full auth shape from `/api/auth/me`. Use when the server-rendered
8
+ * UI needs more than "is there any session" branching on tenant or
9
+ * role, scoping a query to the active tenant, etc.
20
10
  */
21
11
  export type PylonAuth = {
22
12
  userId: string;
@@ -26,20 +16,230 @@ export type PylonAuth = {
26
16
  };
27
17
 
28
18
  /**
29
- * Options for the server-side helpers. Both default to env vars:
30
- * `PYLON_COOKIE_NAME` (fallback `pylon_session`) and `PYLON_TARGET`
31
- * (fallback `http://localhost:4321` in dev only required in prod).
19
+ * Configuration for {@link createPylonServer}.
20
+ *
21
+ * `cookieName` is REQUIREDthere's no safe default because Pylon's
22
+ * binary emits `${app_name}_session` (e.g. `pylon-cloud_session`) and
23
+ * the package can't know your app name. Passing it explicitly here
24
+ * also kills a class of bugs where a wrong env var silently breaks
25
+ * auth in production.
26
+ *
27
+ * `target` is the Pylon control-plane origin. Defaults to the
28
+ * `PYLON_TARGET` env var; throws in production if unset.
29
+ *
30
+ * `getMeFn` is the server function name used by {@link PylonServer.getMe}.
31
+ * Default `"getMe"` — most apps just declare a `functions/getMe.ts`
32
+ * that returns the current user's safe-to-display fields, see the
33
+ * Pylon Cloud reference for an example.
34
+ *
35
+ * `loginUrl` is where {@link PylonServer.requireAuth} / {@link
36
+ * PylonServer.requireMe} redirect when the session is missing.
32
37
  */
33
- export type SessionOptions = {
34
- cookieName?: string;
38
+ export type PylonServerConfig = {
39
+ cookieName: string;
35
40
  target?: string;
41
+ getMeFn?: string;
42
+ loginUrl?: string;
36
43
  };
37
44
 
38
- function resolveOpts(opts: SessionOptions = {}) {
45
+ /**
46
+ * Bound server helpers — built once per app via {@link createPylonServer}
47
+ * and used everywhere. Eliminates the per-call cookieName / target
48
+ * plumbing the standalone helpers required.
49
+ *
50
+ * ```ts
51
+ * // src/lib/pylon.ts
52
+ * export const pylon = createPylonServer({
53
+ * cookieName: "myapp_session",
54
+ * getMeFn: "getMe",
55
+ * });
56
+ *
57
+ * // src/app/dashboard/layout.tsx
58
+ * import { pylon } from "@/lib/pylon";
59
+ * const me = await pylon.requireMe<User>();
60
+ * const orgs = await pylon.json<Org[]>("/api/entities/Organization");
61
+ * ```
62
+ */
63
+ export interface PylonServer {
64
+ /** Forwarded raw fetch — caller handles status + body parsing. */
65
+ fetch(path: string, init?: RequestInit): Promise<Response>;
66
+ /**
67
+ * Fetch + parse + status check in one. Throws {@link ApiError} on
68
+ * non-2xx so callers don't have to write the `if (!res.ok)` dance
69
+ * before every `.json()`.
70
+ */
71
+ json<T = unknown>(path: string, init?: RequestInit): Promise<T>;
72
+ /** Resolved auth + null on no session. */
73
+ getAuth(): Promise<PylonAuth | null>;
74
+ /** Resolved auth, or `redirect()` to `loginUrl`. */
75
+ requireAuth(): Promise<PylonAuth>;
76
+ /**
77
+ * OAuth provider list, server-side. Eliminates the post-mount
78
+ * flicker the client `useOAuthProviders` causes.
79
+ */
80
+ getOAuthProviders(): Promise<OAuthProvider[]>;
81
+ /**
82
+ * Current user (auth + the row your `getMe` function returns).
83
+ * Calls `/api/fn/${getMeFn}` rather than the entity API — the
84
+ * function bypasses entity policies and lets you control the
85
+ * projection (typically: id, email, displayName; never
86
+ * passwordHash).
87
+ */
88
+ getMe<U = Record<string, unknown>>(): Promise<{
89
+ auth: PylonAuth;
90
+ user: U;
91
+ } | null>;
92
+ /** Like `getMe`, redirects to `loginUrl` on null. */
93
+ requireMe<U = Record<string, unknown>>(): Promise<{
94
+ auth: PylonAuth;
95
+ user: U;
96
+ }>;
97
+ }
98
+
99
+ /**
100
+ * Build a server-side Pylon helper, bound to one app's configuration.
101
+ * One factory call per app, no per-call boilerplate.
102
+ *
103
+ * See {@link PylonServerConfig} for the required options.
104
+ */
105
+ export function createPylonServer(config: PylonServerConfig): PylonServer {
106
+ const cookieName = config.cookieName;
107
+ const targetOpt = config.target;
108
+ const getMeFn = config.getMeFn ?? "getMe";
109
+ const loginUrl = config.loginUrl ?? "/login";
110
+
111
+ const target = (): string => resolveTarget(targetOpt);
112
+
113
+ async function readSession(): Promise<{
114
+ header: string;
115
+ value: string;
116
+ } | null> {
117
+ const cookieStore = await cookies();
118
+ const c = cookieStore.get(cookieName);
119
+ if (!c) return null;
120
+ return { header: `${cookieName}=${c.value}`, value: c.value };
121
+ }
122
+
123
+ async function getAuth(): Promise<PylonAuth | null> {
124
+ const session = await readSession();
125
+ if (!session) return null;
126
+ const auth = await fetch(`${target()}/api/auth/me`, {
127
+ headers: { cookie: session.header },
128
+ cache: "no-store",
129
+ })
130
+ .then(
131
+ (r) =>
132
+ r.json() as Promise<{
133
+ user_id?: string;
134
+ tenant_id?: string | null;
135
+ is_admin?: boolean;
136
+ }>,
137
+ )
138
+ .catch(
139
+ () =>
140
+ ({}) as {
141
+ user_id?: string;
142
+ tenant_id?: string | null;
143
+ is_admin?: boolean;
144
+ },
145
+ );
146
+ if (!auth.user_id) return null;
147
+ return {
148
+ userId: auth.user_id,
149
+ tenantId: auth.tenant_id ?? null,
150
+ isAdmin: auth.is_admin ?? false,
151
+ cookieHeader: session.header,
152
+ };
153
+ }
154
+
155
+ async function requireAuth(): Promise<PylonAuth> {
156
+ const a = await getAuth();
157
+ if (!a) redirect(loginUrl);
158
+ return a;
159
+ }
160
+
161
+ async function pylonFetchBound(
162
+ path: string,
163
+ init: RequestInit = {},
164
+ ): Promise<Response> {
165
+ const session = await readSession();
166
+ const headers = new Headers(init.headers);
167
+ if (session) headers.set("cookie", session.header);
168
+ return fetch(`${target()}${path}`, {
169
+ cache: "no-store",
170
+ ...init,
171
+ headers,
172
+ });
173
+ }
174
+
175
+ async function pylonJsonBound<T = unknown>(
176
+ path: string,
177
+ init: RequestInit = {},
178
+ ): Promise<T> {
179
+ const res = await pylonFetchBound(path, init);
180
+ const text = await res.text();
181
+ const body = text ? JSON.parse(text) : null;
182
+ if (!res.ok) {
183
+ const code = body?.error?.code ?? body?.code ?? "UNKNOWN";
184
+ const msg = body?.error?.message ?? body?.message ?? res.statusText;
185
+ throw new ApiError(res.status, code, msg);
186
+ }
187
+ return body as T;
188
+ }
189
+
190
+ async function getOAuthProvidersBound(): Promise<OAuthProvider[]> {
191
+ // Providers are env-derived on the control plane; they don't
192
+ // change per-request but DO change across deploys. no-store is
193
+ // the safe default until a caller opts into caching.
194
+ try {
195
+ const res = await fetch(`${target()}/api/auth/providers`, {
196
+ cache: "no-store",
197
+ });
198
+ if (!res.ok) return [];
199
+ return (await res.json()) as OAuthProvider[];
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ async function getMe<U = Record<string, unknown>>(): Promise<{
206
+ auth: PylonAuth;
207
+ user: U;
208
+ } | null> {
209
+ const auth = await getAuth();
210
+ if (!auth) return null;
211
+ try {
212
+ const user = await pylonJsonBound<U>(`/api/fn/${getMeFn}`, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: "{}",
216
+ });
217
+ if (user == null) return null;
218
+ return { auth, user };
219
+ } catch {
220
+ // Function may not be registered yet, or the row was
221
+ // deleted while logged in — treat as anonymous.
222
+ return null;
223
+ }
224
+ }
225
+
226
+ async function requireMe<U = Record<string, unknown>>(): Promise<{
227
+ auth: PylonAuth;
228
+ user: U;
229
+ }> {
230
+ const me = await getMe<U>();
231
+ if (!me) redirect(loginUrl);
232
+ return me;
233
+ }
234
+
39
235
  return {
40
- cookieName:
41
- opts.cookieName ?? process.env.PYLON_COOKIE_NAME ?? "pylon_session",
42
- target: opts.target ?? resolveTarget(),
236
+ fetch: pylonFetchBound,
237
+ json: pylonJsonBound,
238
+ getAuth,
239
+ requireAuth,
240
+ getOAuthProviders: getOAuthProvidersBound,
241
+ getMe,
242
+ requireMe,
43
243
  };
44
244
  }
45
245
 
@@ -50,7 +250,8 @@ function resolveOpts(opts: SessionOptions = {}) {
50
250
  * on localhost:4321 (sidecar, debug shim, nothing). Throw loudly so
51
251
  * the misconfiguration surfaces immediately on the first request.
52
252
  */
53
- function resolveTarget(): string {
253
+ function resolveTarget(target?: string): string {
254
+ if (target && target.length > 0) return target;
54
255
  const env = process.env.PYLON_TARGET;
55
256
  if (env && env.length > 0) return env;
56
257
  if (process.env.NODE_ENV === "production") {
@@ -61,208 +262,76 @@ function resolveTarget(): string {
61
262
  return "http://localhost:4321";
62
263
  }
63
264
 
64
- /**
65
- * Read the Pylon session cookie and validate it server-side via
66
- * `/api/auth/me`. Returns `null` if the cookie is missing or the
67
- * session has been revoked / expired. Suitable for layouts that want
68
- * to render different UI for anonymous vs. authenticated users.
69
- *
70
- * Use {@link requirePylonSession} if you'd rather just redirect.
71
- */
72
- export async function getPylonSession(
73
- opts?: SessionOptions,
74
- ): Promise<PylonSession | null> {
75
- const { cookieName, target } = resolveOpts(opts);
76
- const cookieStore = await cookies();
77
- const session = cookieStore.get(cookieName);
78
- if (!session) return null;
79
-
80
- const cookieHeader = `${cookieName}=${session.value}`;
81
- const auth = await fetch(`${target}/api/auth/me`, {
82
- headers: { cookie: cookieHeader },
83
- cache: "no-store",
84
- })
85
- .then((r) => r.json() as Promise<{ user_id?: string }>)
86
- .catch(() => ({}) as { user_id?: string });
87
- if (!auth.user_id) return null;
88
- return { userId: auth.user_id, cookieHeader };
89
- }
265
+ // ---------------------------------------------------------------------------
266
+ // Standalone helpers for callers that want one-off invocations without
267
+ // instantiating a {@link PylonServer}. Most apps should prefer
268
+ // {@link createPylonServer}; these exist for tests, scripts, and
269
+ // migrations.
270
+ // ---------------------------------------------------------------------------
90
271
 
91
272
  /**
92
- * Like {@link getPylonSession} but redirects to `loginUrl` (default
93
- * `/login`) if the session is missing or invalid. Use in Server
94
- * Component layouts to gate a whole subtree without leaking protected
95
- * UI before the redirect.
96
- *
97
- * ```ts
98
- * export default async function DashboardLayout({ children }) {
99
- * const { userId, cookieHeader } = await requirePylonSession();
100
- * const me = await fetchMe(userId, cookieHeader);
101
- * return <Chrome user={me}>{children}</Chrome>;
102
- * }
103
- * ```
273
+ * One-shot version of {@link PylonServer.getAuth}. Pass the cookie
274
+ * name explicitly the package no longer reads PYLON_COOKIE_NAME
275
+ * from env (silently-overridable env-driven config was a footgun in
276
+ * practice).
104
277
  */
105
- export async function requirePylonSession(
106
- opts?: SessionOptions & { loginUrl?: string },
107
- ): Promise<PylonSession> {
108
- const session = await getPylonSession(opts);
109
- if (!session) redirect(opts?.loginUrl ?? "/login");
110
- return session;
111
- }
112
-
113
- /**
114
- * Like {@link getPylonSession} but returns the full auth shape —
115
- * userId + tenantId + isAdmin. Use when the server-rendered UI
116
- * needs more than "is there any session" (e.g. scoping a query to
117
- * the active tenant, showing an admin-only menu).
118
- *
119
- * Returns `null` if no session cookie is present, or the cookie's
120
- * session has been revoked / expired.
121
- *
122
- * ```ts
123
- * const auth = await getAuth();
124
- * if (!auth) redirect("/login");
125
- * if (!auth.tenantId) redirect("/onboarding");
126
- * ```
127
- */
128
- export async function getAuth(
129
- opts?: SessionOptions,
130
- ): Promise<PylonAuth | null> {
131
- const { cookieName, target } = resolveOpts(opts);
132
- const cookieStore = await cookies();
133
- const session = cookieStore.get(cookieName);
134
- if (!session) return null;
135
-
136
- const cookieHeader = `${cookieName}=${session.value}`;
137
- const auth = await fetch(`${target}/api/auth/me`, {
138
- headers: { cookie: cookieHeader },
139
- cache: "no-store",
140
- })
141
- .then(
142
- (r) =>
143
- r.json() as Promise<{
144
- user_id?: string;
145
- tenant_id?: string | null;
146
- is_admin?: boolean;
147
- }>,
148
- )
149
- .catch(
150
- () =>
151
- ({}) as {
152
- user_id?: string;
153
- tenant_id?: string | null;
154
- is_admin?: boolean;
155
- },
156
- );
157
- if (!auth.user_id) return null;
158
- return {
159
- userId: auth.user_id,
160
- tenantId: auth.tenant_id ?? null,
161
- isAdmin: auth.is_admin ?? false,
162
- cookieHeader,
163
- };
278
+ export async function getAuth(opts: {
279
+ cookieName: string;
280
+ target?: string;
281
+ }): Promise<PylonAuth | null> {
282
+ return createPylonServer({
283
+ cookieName: opts.cookieName,
284
+ target: opts.target,
285
+ }).getAuth();
164
286
  }
165
287
 
166
- /**
167
- * Like {@link getAuth} but redirects to `loginUrl` (default `/login`)
168
- * if no session. The non-null return type frees layouts from the
169
- * `if (!auth) redirect(...)` guard.
170
- */
171
- export async function requireAuth(
172
- opts?: SessionOptions & { loginUrl?: string },
173
- ): Promise<PylonAuth> {
174
- const auth = await getAuth(opts);
175
- if (!auth) redirect(opts?.loginUrl ?? "/login");
176
- return auth;
288
+ /** One-shot version of {@link PylonServer.requireAuth}. */
289
+ export async function requireAuth(opts: {
290
+ cookieName: string;
291
+ target?: string;
292
+ loginUrl?: string;
293
+ }): Promise<PylonAuth> {
294
+ return createPylonServer(opts).requireAuth();
177
295
  }
178
296
 
179
- /**
180
- * Fetch the authed user's row from the User entity in addition to
181
- * resolving auth. Eliminates the "header chrome renders empty for a
182
- * frame, then the username pops in" flicker on dashboard layouts.
183
- *
184
- * The User shape is app-defined (different Pylon apps add their own
185
- * fields beyond the base `email`/`displayName`). Pass your `User`
186
- * type as the generic so the return value is correctly shaped.
187
- *
188
- * ```ts
189
- * type User = { id: string; email: string; displayName: string };
190
- *
191
- * const me = await getCurrentUser<User>();
192
- * if (!me) redirect("/login");
193
- * return <Chrome user={me.user} />;
194
- * ```
195
- *
196
- * Returns `null` when there's no session OR the user row can't be
197
- * loaded (deleted account, transient API failure). Most layouts
198
- * should treat both cases as "redirect to login" — see
199
- * {@link requireCurrentUser}.
200
- */
201
- export async function getCurrentUser<U = Record<string, unknown>>(
202
- opts?: SessionOptions,
203
- ): Promise<{ auth: PylonAuth; user: U } | null> {
204
- const auth = await getAuth(opts);
205
- if (!auth) return null;
206
- const { target } = resolveOpts(opts);
207
- const res = await fetch(
208
- `${target}/api/entities/User/${encodeURIComponent(auth.userId)}`,
209
- { headers: { cookie: auth.cookieHeader }, cache: "no-store" },
210
- );
211
- if (!res.ok) return null;
212
- const user = (await res.json()) as U;
213
- return { auth, user };
297
+ /** One-shot version of {@link PylonServer.fetch}. */
298
+ export async function pylonFetch(
299
+ path: string,
300
+ init?: RequestInit,
301
+ opts?: { cookieName: string; target?: string },
302
+ ): Promise<Response> {
303
+ if (!opts) {
304
+ throw new Error(
305
+ "pylonFetch requires an `opts` argument with `cookieName`. The package no longer reads PYLON_COOKIE_NAME from env to avoid silent breakage from misconfigured envs.",
306
+ );
307
+ }
308
+ return createPylonServer(opts).fetch(path, init);
214
309
  }
215
310
 
216
- /**
217
- * Like {@link getCurrentUser} but redirects to `loginUrl` (default
218
- * `/login`) if the session or user can't be resolved.
219
- */
220
- export async function requireCurrentUser<U = Record<string, unknown>>(
221
- opts?: SessionOptions & { loginUrl?: string },
222
- ): Promise<{ auth: PylonAuth; user: U }> {
223
- const me = await getCurrentUser<U>(opts);
224
- if (!me) redirect(opts?.loginUrl ?? "/login");
225
- return me;
311
+ /** One-shot version of {@link PylonServer.json}. */
312
+ export async function pylonJson<T = unknown>(
313
+ path: string,
314
+ init?: RequestInit,
315
+ opts?: { cookieName: string; target?: string },
316
+ ): Promise<T> {
317
+ if (!opts) {
318
+ throw new Error(
319
+ "pylonJson requires an `opts` argument with `cookieName`.",
320
+ );
321
+ }
322
+ return createPylonServer(opts).json<T>(path, init);
226
323
  }
227
324
 
228
325
  /**
229
- * Server-side fetch of the enabled OAuth providers. Use in Server
230
- * Components for /login and /signup so the "Continue with Google"
231
- * row paints in the initial HTML — no post-mount flicker like the
232
- * client-side {@link useOAuthProviders} hook causes.
233
- *
234
- * Returns an empty array on any failure (control plane unreachable,
235
- * 5xx, etc.) so the page can fall back to rendering the password
236
- * form alone instead of crashing.
237
- *
238
- * ```tsx
239
- * // app/login/page.tsx
240
- * import { getOAuthProviders } from "@pylonsync/next/server";
241
- * import { LoginForm } from "./login-form"; // "use client"
242
- *
243
- * export default async function LoginPage() {
244
- * const providers = await getOAuthProviders();
245
- * return <LoginForm providers={providers} />;
246
- * }
247
- * ```
248
- *
249
- * Hits PYLON_TARGET directly rather than going through the Next
250
- * /api/* rewrite — the rewrite is a browser-side same-origin
251
- * optimization, irrelevant on the server, and skipping it avoids a
252
- * pointless localhost→localhost hop in dev + a real network round
253
- * trip to ourselves in prod.
326
+ * One-shot OAuth provider list. Doesn't need a cookie (the endpoint
327
+ * is public), but does need a `target` resolution.
254
328
  */
255
- export async function getOAuthProviders(
256
- opts?: Pick<SessionOptions, "target">,
257
- ): Promise<OAuthProvider[]> {
258
- const { target } = resolveOpts(opts);
329
+ export async function getOAuthProviders(opts: {
330
+ target?: string;
331
+ } = {}): Promise<OAuthProvider[]> {
332
+ const target = resolveTarget(opts.target);
259
333
  try {
260
334
  const res = await fetch(`${target}/api/auth/providers`, {
261
- // Providers are env-derived on the control plane (set when
262
- // PYLON_OAUTH_*_CLIENT_ID is configured). They don't change
263
- // per-request, but they DO change across deploys. no-store
264
- // is the safest default until callers explicitly opt in to
265
- // caching via revalidate.
266
335
  cache: "no-store",
267
336
  });
268
337
  if (!res.ok) return [];
@@ -271,33 +340,3 @@ export async function getOAuthProviders(
271
340
  return [];
272
341
  }
273
342
  }
274
-
275
- /**
276
- * Server-side fetch to the Pylon control plane that auto-forwards the
277
- * caller's session cookie. Use from Server Components, Route Handlers,
278
- * and Server Actions to call Pylon as the user.
279
- *
280
- * ```ts
281
- * const me: Me = await pylonFetch(`/api/entities/User/${userId}`)
282
- * .then(r => r.json());
283
- * ```
284
- *
285
- * Defaults to `cache: "no-store"` because Pylon responses are
286
- * per-user; pass an explicit `cache` to override.
287
- */
288
- export async function pylonFetch(
289
- path: string,
290
- init: RequestInit = {},
291
- opts?: SessionOptions,
292
- ): Promise<Response> {
293
- const { cookieName, target } = resolveOpts(opts);
294
- const cookieStore = await cookies();
295
- const session = cookieStore.get(cookieName);
296
- const headers = new Headers(init.headers);
297
- if (session) headers.set("cookie", `${cookieName}=${session.value}`);
298
- return fetch(`${target}${path}`, {
299
- cache: "no-store",
300
- ...init,
301
- headers,
302
- });
303
- }