@pylonsync/next 0.2.8

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 ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@pylonsync/next",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.2.8",
7
+ "type": "module",
8
+ "description": "Next.js helpers for Pylon — cookie-based auth gate, server-side session helpers, and reusable client hooks.",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./proxy": "./src/proxy.ts",
12
+ "./server": "./src/server.ts",
13
+ "./client": "./src/client.ts",
14
+ "./auth": "./src/auth.ts"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json --noEmit",
18
+ "check": "tsc -p tsconfig.json --noEmit"
19
+ },
20
+ "peerDependencies": {
21
+ "next": ">=15.0.0 <17.0.0",
22
+ "react": ">=18.0.0 <20.0.0",
23
+ "react-dom": ">=18.0.0 <20.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/react": "^19.0.0",
27
+ "next": "^16.0.0",
28
+ "react": "^19.0.0",
29
+ "react-dom": "^19.0.0",
30
+ "typescript": "^5.0.0"
31
+ }
32
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,159 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { ApiError, api } from "./client";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export type Session = {
11
+ token: string;
12
+ user_id: string;
13
+ expires_at: number;
14
+ };
15
+
16
+ export type OAuthProvider = {
17
+ provider: "google" | "github";
18
+ auth_url: string;
19
+ };
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Direct API helpers
23
+ //
24
+ // Each one wraps a Pylon `/api/auth/*` endpoint. They're plain async
25
+ // functions so callers can compose them however they want — see the
26
+ // hooks below for the typical "form submit" pattern.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export async function signupWithPassword(input: {
30
+ email: string;
31
+ password: string;
32
+ displayName?: string;
33
+ }): Promise<Session> {
34
+ return api<Session>("/api/auth/password/register", {
35
+ method: "POST",
36
+ body: JSON.stringify(input),
37
+ });
38
+ }
39
+
40
+ export async function loginWithPassword(input: {
41
+ email: string;
42
+ password: string;
43
+ }): Promise<Session> {
44
+ return api<Session>("/api/auth/password/login", {
45
+ method: "POST",
46
+ body: JSON.stringify(input),
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Sign the user out. Best-effort — even if the server-side revoke
52
+ * fails (e.g. session already expired) the clearing Set-Cookie comes
53
+ * back so the browser drops the cookie either way.
54
+ */
55
+ export async function logout(): Promise<void> {
56
+ try {
57
+ await api("/api/auth/session", { method: "DELETE" });
58
+ } catch {
59
+ // ignore — token may already be expired/invalid
60
+ }
61
+ }
62
+
63
+ export async function listOAuthProviders(): Promise<OAuthProvider[]> {
64
+ return api<OAuthProvider[]>("/api/auth/providers");
65
+ }
66
+
67
+ /**
68
+ * Kick off an OAuth login. Browser navigates to Pylon's GET login
69
+ * route, which 302s to the provider, which 302s back to Pylon's GET
70
+ * callback (Set-Cookie + 302 to PYLON_DASHBOARD_URL). The OAuth code
71
+ * never enters JS, so XSS in the dashboard can't intercept the
72
+ * handshake.
73
+ */
74
+ export function startOAuthLogin(provider: OAuthProvider["provider"]): void {
75
+ window.location.href = `/api/auth/login/${provider}?redirect=1`;
76
+ }
77
+
78
+ export async function sendVerificationEmail(): Promise<{
79
+ sent: boolean;
80
+ email: string;
81
+ dev_code?: string;
82
+ }> {
83
+ return api("/api/auth/email/send-verification", { method: "POST" });
84
+ }
85
+
86
+ export async function verifyEmail(
87
+ code: string,
88
+ ): Promise<{ verified: boolean; emailVerified: string }> {
89
+ return api("/api/auth/email/verify", {
90
+ method: "POST",
91
+ body: JSON.stringify({ code }),
92
+ });
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Hooks — form-shaped wrappers that handle the common busy/error state
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Fetch the enabled OAuth providers on mount. Returns an empty array
101
+ * until the request resolves, so callers can render the password form
102
+ * unconditionally and conditionally show the OAuth button row when
103
+ * `providers.length > 0`.
104
+ */
105
+ export function useOAuthProviders(): OAuthProvider[] {
106
+ const [providers, setProviders] = useState<OAuthProvider[]>([]);
107
+ useEffect(() => {
108
+ listOAuthProviders()
109
+ .then(setProviders)
110
+ .catch(() => {});
111
+ }, []);
112
+ return providers;
113
+ }
114
+
115
+ /**
116
+ * Manage busy + error state for an auth-shaped async submission.
117
+ * Wraps the provided `fn` so callers don't have to write
118
+ * `try/catch/finally` around every form submit.
119
+ *
120
+ * ```tsx
121
+ * const { error, busy, submit } = useAuthSubmit(loginWithPassword);
122
+ *
123
+ * async function onSubmit(e: React.FormEvent) {
124
+ * e.preventDefault();
125
+ * const ok = await submit({ email, password });
126
+ * if (ok) router.push("/dashboard");
127
+ * }
128
+ * ```
129
+ *
130
+ * `submit` returns `undefined` on failure (error is set internally),
131
+ * the awaited resolution otherwise. `setError` is exposed for the
132
+ * rare case a page wants to clear the error programmatically.
133
+ */
134
+ export function useAuthSubmit<I, O>(fn: (input: I) => Promise<O>): {
135
+ error: string | null;
136
+ busy: boolean;
137
+ submit: (input: I) => Promise<O | undefined>;
138
+ setError: (e: string | null) => void;
139
+ } {
140
+ const [error, setError] = useState<string | null>(null);
141
+ const [busy, setBusy] = useState(false);
142
+ const submit = useCallback(
143
+ async (input: I): Promise<O | undefined> => {
144
+ setError(null);
145
+ setBusy(true);
146
+ try {
147
+ return await fn(input);
148
+ } catch (err) {
149
+ if (err instanceof ApiError) setError(err.message);
150
+ else setError(err instanceof Error ? err.message : String(err));
151
+ return undefined;
152
+ } finally {
153
+ setBusy(false);
154
+ }
155
+ },
156
+ [fn],
157
+ );
158
+ return { error, busy, submit, setError };
159
+ }
package/src/client.ts ADDED
@@ -0,0 +1,80 @@
1
+ "use client";
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
+ }
17
+
18
+ /**
19
+ * Options for {@link createPylonClient}.
20
+ */
21
+ export type CreatePylonClientOptions = {
22
+ /**
23
+ * Base URL prefixed to every request. Empty string (default) means
24
+ * same-origin — the right choice when Next is proxying `/api/*`
25
+ * to the Pylon backend via `next.config.ts` rewrites, because the
26
+ * browser sees same-origin and the cookie is auto-attached.
27
+ */
28
+ baseUrl?: string;
29
+ };
30
+
31
+ /**
32
+ * Build a typed `api()` function for the dashboard. Sends `credentials:
33
+ * "include"` so the HttpOnly Pylon session cookie rides along on every
34
+ * request — there's no token in JS to steal.
35
+ *
36
+ * ```ts
37
+ * import { createPylonClient } from "@pylonsync/next/client";
38
+ * export const api = createPylonClient();
39
+ *
40
+ * const me = await api<Me>("/api/entities/User/abc");
41
+ * ```
42
+ *
43
+ * Throws {@link ApiError} on non-2xx so callers can `instanceof` it
44
+ * and surface `.code` + `.message` near the form that triggered the
45
+ * call.
46
+ */
47
+ export function createPylonClient(opts: CreatePylonClientOptions = {}) {
48
+ const baseUrl = opts.baseUrl ?? "";
49
+ return async function api<T = unknown>(
50
+ path: string,
51
+ init: RequestInit = {},
52
+ ): Promise<T> {
53
+ const headers: Record<string, string> = {
54
+ "Content-Type": "application/json",
55
+ ...((init.headers as Record<string, string>) ?? {}),
56
+ };
57
+ const res = await fetch(`${baseUrl}${path}`, {
58
+ ...init,
59
+ headers,
60
+ credentials: "include",
61
+ });
62
+ const text = await res.text();
63
+ // 204 / empty body → null. Callers that know the endpoint
64
+ // returns nothing don't have to special-case.
65
+ const body = text ? JSON.parse(text) : null;
66
+ if (!res.ok) {
67
+ const code = body?.error?.code ?? body?.code ?? "UNKNOWN";
68
+ const msg = body?.error?.message ?? body?.message ?? res.statusText;
69
+ throw new ApiError(res.status, code, msg);
70
+ }
71
+ return body as T;
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Default client: same-origin, paired with the Next.js `/api/*`
77
+ * rewrite. Most apps use this directly; call {@link createPylonClient}
78
+ * if you need a different `baseUrl`.
79
+ */
80
+ export const api = createPylonClient();
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Top-level re-exports for the most common items. Server-only
2
+ // helpers live at the `@pylonsync/next/server` subpath so they
3
+ // don't accidentally land in client bundles; client-only hooks
4
+ // live at `@pylonsync/next/auth` for the same reason.
5
+
6
+ export type { OAuthProvider, Session } from "./auth";
7
+ export { ApiError } from "./client";
package/src/proxy.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+
4
+ /**
5
+ * Options for {@link createPylonProxy}.
6
+ */
7
+ export type CreatePylonProxyOptions = {
8
+ /**
9
+ * Cookie name to look for. Defaults to `process.env.PYLON_COOKIE_NAME`,
10
+ * falling back to `pylon_session`. Should match the Pylon backend's
11
+ * `PYLON_COOKIE_NAME` (or `${app_name}_session` if unset there).
12
+ */
13
+ cookieName?: string;
14
+ /**
15
+ * Where to redirect unauthenticated requests. Defaults to `/login`.
16
+ * The original path is preserved in `?next=…` so the login page can
17
+ * bounce the user back after sign-in.
18
+ */
19
+ loginUrl?: string;
20
+ /**
21
+ * Routes the proxy applies to. Forms the `config.matcher` Next reads.
22
+ * Defaults to `["/dashboard/:path*"]`.
23
+ */
24
+ matcher?: string[];
25
+ };
26
+
27
+ /**
28
+ * Build a Next 16 `proxy.ts` that gates routes on the presence of the
29
+ * Pylon session cookie. Drop the result into `src/proxy.ts`:
30
+ *
31
+ * ```ts
32
+ * import { createPylonProxy } from "@pylonsync/next/proxy";
33
+ *
34
+ * const { proxy, config } = createPylonProxy({
35
+ * matcher: ["/dashboard/:path*", "/onboarding/:path*"],
36
+ * });
37
+ *
38
+ * export { proxy, config };
39
+ * ```
40
+ *
41
+ * The proxy only checks cookie *presence* — a forged value will fail the
42
+ * server-side `/api/auth/me` revalidation in your layout. Its job is to
43
+ * short-circuit the obvious "no session at all" case before any page
44
+ * renders, so users never see protected UI flash before the redirect.
45
+ */
46
+ export function createPylonProxy(opts: CreatePylonProxyOptions = {}) {
47
+ const cookieName =
48
+ opts.cookieName ?? process.env.PYLON_COOKIE_NAME ?? "pylon_session";
49
+ const loginUrl = opts.loginUrl ?? "/login";
50
+ const matcher = opts.matcher ?? ["/dashboard/:path*"];
51
+
52
+ function proxy(request: NextRequest) {
53
+ const session = request.cookies.get(cookieName);
54
+ if (session) return NextResponse.next();
55
+
56
+ const url = request.nextUrl.clone();
57
+ url.pathname = loginUrl;
58
+ // Preserve the original destination so the login page can bounce
59
+ // the user back after sign-in (read `?next=`).
60
+ url.searchParams.set("next", request.nextUrl.pathname);
61
+ return NextResponse.redirect(url);
62
+ }
63
+
64
+ return { proxy, config: { matcher } };
65
+ }
package/src/server.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { cookies } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+
4
+ /**
5
+ * Resolved Pylon session for a server-side request. Use `cookieHeader`
6
+ * when forwarding the cookie to subsequent Pylon API calls (see
7
+ * {@link pylonFetch}).
8
+ */
9
+ export type PylonSession = {
10
+ userId: string;
11
+ cookieHeader: string;
12
+ };
13
+
14
+ /**
15
+ * Options for the server-side helpers. Both default to env vars:
16
+ * `PYLON_COOKIE_NAME` (fallback `pylon_session`) and `PYLON_TARGET`
17
+ * (fallback `http://localhost:4321`).
18
+ */
19
+ export type SessionOptions = {
20
+ cookieName?: string;
21
+ target?: string;
22
+ };
23
+
24
+ function resolveOpts(opts: SessionOptions = {}) {
25
+ return {
26
+ cookieName:
27
+ opts.cookieName ?? process.env.PYLON_COOKIE_NAME ?? "pylon_session",
28
+ target:
29
+ opts.target ?? process.env.PYLON_TARGET ?? "http://localhost:4321",
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Read the Pylon session cookie and validate it server-side via
35
+ * `/api/auth/me`. Returns `null` if the cookie is missing or the
36
+ * session has been revoked / expired. Suitable for layouts that want
37
+ * to render different UI for anonymous vs. authenticated users.
38
+ *
39
+ * Use {@link requirePylonSession} if you'd rather just redirect.
40
+ */
41
+ export async function getPylonSession(
42
+ opts?: SessionOptions,
43
+ ): Promise<PylonSession | null> {
44
+ const { cookieName, target } = resolveOpts(opts);
45
+ const cookieStore = await cookies();
46
+ const session = cookieStore.get(cookieName);
47
+ if (!session) return null;
48
+
49
+ const cookieHeader = `${cookieName}=${session.value}`;
50
+ const auth = await fetch(`${target}/api/auth/me`, {
51
+ headers: { cookie: cookieHeader },
52
+ cache: "no-store",
53
+ })
54
+ .then((r) => r.json() as Promise<{ user_id?: string }>)
55
+ .catch(() => ({}) as { user_id?: string });
56
+ if (!auth.user_id) return null;
57
+ return { userId: auth.user_id, cookieHeader };
58
+ }
59
+
60
+ /**
61
+ * Like {@link getPylonSession} but redirects to `loginUrl` (default
62
+ * `/login`) if the session is missing or invalid. Use in Server
63
+ * Component layouts to gate a whole subtree without leaking protected
64
+ * UI before the redirect.
65
+ *
66
+ * ```ts
67
+ * export default async function DashboardLayout({ children }) {
68
+ * const { userId, cookieHeader } = await requirePylonSession();
69
+ * const me = await fetchMe(userId, cookieHeader);
70
+ * return <Chrome user={me}>{children}</Chrome>;
71
+ * }
72
+ * ```
73
+ */
74
+ export async function requirePylonSession(
75
+ opts?: SessionOptions & { loginUrl?: string },
76
+ ): Promise<PylonSession> {
77
+ const session = await getPylonSession(opts);
78
+ if (!session) redirect(opts?.loginUrl ?? "/login");
79
+ return session;
80
+ }
81
+
82
+ /**
83
+ * Server-side fetch to the Pylon control plane that auto-forwards the
84
+ * caller's session cookie. Use from Server Components, Route Handlers,
85
+ * and Server Actions to call Pylon as the user.
86
+ *
87
+ * ```ts
88
+ * const me: Me = await pylonFetch(`/api/entities/User/${userId}`)
89
+ * .then(r => r.json());
90
+ * ```
91
+ *
92
+ * Defaults to `cache: "no-store"` because Pylon responses are
93
+ * per-user; pass an explicit `cache` to override.
94
+ */
95
+ export async function pylonFetch(
96
+ path: string,
97
+ init: RequestInit = {},
98
+ opts?: SessionOptions,
99
+ ): Promise<Response> {
100
+ const { cookieName, target } = resolveOpts(opts);
101
+ const cookieStore = await cookies();
102
+ const session = cookieStore.get(cookieName);
103
+ const headers = new Headers(init.headers);
104
+ if (session) headers.set("cookie", `${cookieName}=${session.value}`);
105
+ return fetch(`${target}${path}`, {
106
+ cache: "no-store",
107
+ ...init,
108
+ headers,
109
+ });
110
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
5
+ },
6
+ "include": ["src"]
7
+ }