@pylonsync/next 0.2.8 → 0.2.10

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 +1 -1
  2. package/src/server.ts +196 -3
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.8",
6
+ "version": "0.2.10",
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/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cookies } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
+ import type { OAuthProvider } from "./auth";
3
4
 
4
5
  /**
5
6
  * Resolved Pylon session for a server-side request. Use `cookieHeader`
@@ -11,10 +12,23 @@ export type PylonSession = {
11
12
  cookieHeader: string;
12
13
  };
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).
20
+ */
21
+ export type PylonAuth = {
22
+ userId: string;
23
+ tenantId: string | null;
24
+ isAdmin: boolean;
25
+ cookieHeader: string;
26
+ };
27
+
14
28
  /**
15
29
  * Options for the server-side helpers. Both default to env vars:
16
30
  * `PYLON_COOKIE_NAME` (fallback `pylon_session`) and `PYLON_TARGET`
17
- * (fallback `http://localhost:4321`).
31
+ * (fallback `http://localhost:4321` in dev only — required in prod).
18
32
  */
19
33
  export type SessionOptions = {
20
34
  cookieName?: string;
@@ -25,11 +39,28 @@ function resolveOpts(opts: SessionOptions = {}) {
25
39
  return {
26
40
  cookieName:
27
41
  opts.cookieName ?? process.env.PYLON_COOKIE_NAME ?? "pylon_session",
28
- target:
29
- opts.target ?? process.env.PYLON_TARGET ?? "http://localhost:4321",
42
+ target: opts.target ?? resolveTarget(),
30
43
  };
31
44
  }
32
45
 
46
+ /**
47
+ * Resolve PYLON_TARGET with a prod-safe default. The localhost
48
+ * fallback exists for `next dev` ergonomics — in production it would
49
+ * silently route every server-side Pylon call to whatever is listening
50
+ * on localhost:4321 (sidecar, debug shim, nothing). Throw loudly so
51
+ * the misconfiguration surfaces immediately on the first request.
52
+ */
53
+ function resolveTarget(): string {
54
+ const env = process.env.PYLON_TARGET;
55
+ if (env && env.length > 0) return env;
56
+ if (process.env.NODE_ENV === "production") {
57
+ throw new Error(
58
+ "PYLON_TARGET is required in production. Set it to your Pylon control-plane origin (e.g. https://api.example.com).",
59
+ );
60
+ }
61
+ return "http://localhost:4321";
62
+ }
63
+
33
64
  /**
34
65
  * Read the Pylon session cookie and validate it server-side via
35
66
  * `/api/auth/me`. Returns `null` if the cookie is missing or the
@@ -79,6 +110,168 @@ export async function requirePylonSession(
79
110
  return session;
80
111
  }
81
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
+ };
164
+ }
165
+
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;
177
+ }
178
+
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 };
214
+ }
215
+
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;
226
+ }
227
+
228
+ /**
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.
254
+ */
255
+ export async function getOAuthProviders(
256
+ opts?: Pick<SessionOptions, "target">,
257
+ ): Promise<OAuthProvider[]> {
258
+ const { target } = resolveOpts(opts);
259
+ try {
260
+ 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
+ cache: "no-store",
267
+ });
268
+ if (!res.ok) return [];
269
+ return (await res.json()) as OAuthProvider[];
270
+ } catch {
271
+ return [];
272
+ }
273
+ }
274
+
82
275
  /**
83
276
  * Server-side fetch to the Pylon control plane that auto-forwards the
84
277
  * caller's session cookie. Use from Server Components, Route Handlers,