@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 +184 -0
- package/package.json +1 -1
- package/src/client.ts +3 -14
- package/src/errors.ts +18 -0
- package/src/index.ts +1 -1
- package/src/server.ts +280 -241
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
package/src/client.ts
CHANGED
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
19
|
+
* Configuration for {@link createPylonServer}.
|
|
20
|
+
*
|
|
21
|
+
* `cookieName` is REQUIRED — there'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
|
|
34
|
-
cookieName
|
|
38
|
+
export type PylonServerConfig = {
|
|
39
|
+
cookieName: string;
|
|
35
40
|
target?: string;
|
|
41
|
+
getMeFn?: string;
|
|
42
|
+
loginUrl?: string;
|
|
36
43
|
};
|
|
37
44
|
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
opts
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
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
|
-
|
|
257
|
-
): Promise<OAuthProvider[]> {
|
|
258
|
-
const
|
|
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
|
-
}
|