@pylonsync/next 0.2.8 → 0.2.9
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/package.json +1 -1
- package/src/server.ts +196 -3
package/package.json
CHANGED
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,
|