naystack 1.5.8 → 1.5.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.
- package/README.md +646 -91
- package/dist/auth/constants.d.mts +4 -0
- package/dist/auth/constants.d.ts +4 -0
- package/dist/auth/email/client.d.mts +149 -0
- package/dist/auth/email/client.d.ts +149 -0
- package/dist/auth/email/index.cjs.js +2 -14
- package/dist/auth/email/index.d.mts +41 -1
- package/dist/auth/email/index.d.ts +41 -1
- package/dist/auth/email/index.esm.js +1 -12
- package/dist/auth/email/routes/delete.cjs.js +0 -1
- package/dist/auth/email/routes/delete.d.mts +5 -0
- package/dist/auth/email/routes/delete.d.ts +5 -0
- package/dist/auth/email/routes/delete.esm.js +0 -1
- package/dist/auth/email/routes/get.d.mts +5 -0
- package/dist/auth/email/routes/get.d.ts +5 -0
- package/dist/auth/email/routes/post.cjs.js +0 -1
- package/dist/auth/email/routes/post.d.mts +5 -0
- package/dist/auth/email/routes/post.d.ts +5 -0
- package/dist/auth/email/routes/post.esm.js +0 -1
- package/dist/auth/email/routes/put.cjs.js +0 -1
- package/dist/auth/email/routes/put.d.mts +5 -0
- package/dist/auth/email/routes/put.d.ts +5 -0
- package/dist/auth/email/routes/put.esm.js +0 -1
- package/dist/auth/email/token.d.mts +62 -0
- package/dist/auth/email/token.d.ts +62 -0
- package/dist/auth/email/types.d.mts +22 -0
- package/dist/auth/email/types.d.ts +22 -0
- package/dist/auth/email/utils.cjs.js +0 -12
- package/dist/auth/email/utils.d.mts +41 -2
- package/dist/auth/email/utils.d.ts +41 -2
- package/dist/auth/email/utils.esm.js +0 -11
- package/dist/auth/google/get.d.mts +5 -0
- package/dist/auth/google/get.d.ts +5 -0
- package/dist/auth/google/index.d.mts +39 -0
- package/dist/auth/google/index.d.ts +39 -0
- package/dist/auth/index.cjs.js +4 -16
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.esm.js +3 -14
- package/dist/auth/instagram/client.d.mts +19 -0
- package/dist/auth/instagram/client.d.ts +19 -0
- package/dist/auth/instagram/index.d.mts +37 -0
- package/dist/auth/instagram/index.d.ts +37 -0
- package/dist/auth/instagram/route.d.mts +5 -0
- package/dist/auth/instagram/route.d.ts +5 -0
- package/dist/auth/instagram/utils.d.mts +13 -0
- package/dist/auth/instagram/utils.d.ts +13 -0
- package/dist/auth/types.d.mts +24 -0
- package/dist/auth/types.d.ts +24 -0
- package/dist/auth/utils/errors.d.mts +10 -0
- package/dist/auth/utils/errors.d.ts +10 -0
- package/dist/auth/utils/token.d.mts +20 -0
- package/dist/auth/utils/token.d.ts +20 -0
- package/dist/client/hooks.d.mts +59 -0
- package/dist/client/hooks.d.ts +59 -0
- package/dist/client/seo.d.mts +46 -0
- package/dist/client/seo.d.ts +46 -0
- package/dist/env.d.mts +61 -0
- package/dist/env.d.ts +61 -0
- package/dist/file/client.d.mts +53 -1
- package/dist/file/client.d.ts +53 -1
- package/dist/file/index.cjs.js +0 -1
- package/dist/file/index.esm.js +0 -1
- package/dist/file/put.cjs.js +0 -1
- package/dist/file/put.d.mts +11 -0
- package/dist/file/put.d.ts +11 -0
- package/dist/file/put.esm.js +0 -1
- package/dist/file/setup.cjs.js +0 -1
- package/dist/file/setup.d.mts +48 -0
- package/dist/file/setup.d.ts +48 -0
- package/dist/file/setup.esm.js +0 -1
- package/dist/file/utils.d.mts +41 -0
- package/dist/file/utils.d.ts +41 -0
- package/dist/graphql/client.d.mts +113 -0
- package/dist/graphql/client.d.ts +113 -0
- package/dist/graphql/errors.d.mts +26 -0
- package/dist/graphql/errors.d.ts +26 -0
- package/dist/graphql/index.cjs.js +2 -3
- package/dist/graphql/index.esm.js +2 -3
- package/dist/graphql/init.cjs.js +0 -1
- package/dist/graphql/init.d.mts +33 -0
- package/dist/graphql/init.d.ts +33 -0
- package/dist/graphql/init.esm.js +0 -1
- package/dist/graphql/server.d.mts +88 -0
- package/dist/graphql/server.d.ts +88 -0
- package/dist/graphql/types.d.mts +21 -0
- package/dist/graphql/types.d.ts +21 -0
- package/dist/graphql/utils.d.mts +217 -0
- package/dist/graphql/utils.d.ts +217 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/socials/instagram/getters.d.mts +115 -0
- package/dist/socials/instagram/getters.d.ts +115 -0
- package/dist/socials/instagram/setters.d.mts +18 -0
- package/dist/socials/instagram/setters.d.ts +18 -0
- package/dist/socials/instagram/types.d.mts +46 -0
- package/dist/socials/instagram/types.d.ts +46 -0
- package/dist/socials/instagram/utils.d.mts +19 -0
- package/dist/socials/instagram/utils.d.ts +19 -0
- package/dist/socials/instagram/webhook.d.mts +31 -0
- package/dist/socials/instagram/webhook.d.ts +31 -0
- package/dist/socials/meta-webhook.d.mts +11 -0
- package/dist/socials/meta-webhook.d.ts +11 -0
- package/dist/socials/threads/getters.d.mts +57 -0
- package/dist/socials/threads/getters.d.ts +57 -0
- package/dist/socials/threads/setters.d.mts +59 -0
- package/dist/socials/threads/setters.d.ts +59 -0
- package/dist/socials/threads/types.d.mts +9 -0
- package/dist/socials/threads/types.d.ts +9 -0
- package/dist/socials/threads/utils.d.mts +19 -0
- package/dist/socials/threads/utils.d.ts +19 -0
- package/dist/socials/threads/webhook.d.mts +30 -0
- package/dist/socials/threads/webhook.d.ts +30 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# Naystack
|
|
2
2
|
|
|
3
|
-
A minimal, powerful stack for Next.js app development. Built with **Next.js + Drizzle ORM + GraphQL + S3 Auth**.
|
|
3
|
+
A minimal, powerful stack for Next.js app development. Built with **Next.js + Drizzle ORM + GraphQL + S3 + Auth**.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/naystack)
|
|
6
6
|
[](https://opensource.org/licenses/ISC)
|
|
7
7
|
|
|
8
|
+
**[API Reference](https://abhinaypandey02.github.io/naystack/)**
|
|
9
|
+
|
|
8
10
|
## Installation
|
|
9
11
|
|
|
10
12
|
```bash
|
|
@@ -15,11 +17,11 @@ pnpm add naystack
|
|
|
15
17
|
|
|
16
18
|
## 1. Authentication
|
|
17
19
|
|
|
18
|
-
Naystack provides a seamless email-based authentication system with optional support for Google and Instagram.
|
|
20
|
+
Naystack provides a seamless email-based authentication system with optional support for Google and Instagram OAuth.
|
|
19
21
|
|
|
20
22
|
### Server Setup
|
|
21
23
|
|
|
22
|
-
Define your auth routes in `app/api/(auth)/email/
|
|
24
|
+
Define your auth routes in `app/api/(auth)/email/route.ts`. The library reads `SIGNING_KEY` and `REFRESH_KEY` from environment variables automatically.
|
|
23
25
|
|
|
24
26
|
```typescript
|
|
25
27
|
import { getEmailAuthRoutes } from "naystack/auth";
|
|
@@ -27,109 +29,495 @@ import { db } from "@/app/api/lib/db";
|
|
|
27
29
|
import { UserTable } from "@/app/api/(graphql)/User/db";
|
|
28
30
|
import { eq } from "drizzle-orm";
|
|
29
31
|
|
|
30
|
-
export const { GET, POST, PUT, DELETE
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return user;
|
|
34
|
-
},
|
|
35
|
-
getUser: async (email) => {
|
|
32
|
+
export const { GET, POST, PUT, DELETE } = getEmailAuthRoutes({
|
|
33
|
+
// Fetch user by request data (used for login & sign-up duplicate check)
|
|
34
|
+
getUser: async ({ email }: { email: string }) => {
|
|
36
35
|
const [user] = await db
|
|
37
|
-
.select({
|
|
38
|
-
id: UserTable.id,
|
|
39
|
-
email: UserTable.email,
|
|
40
|
-
password: UserTable.password,
|
|
41
|
-
})
|
|
36
|
+
.select({ id: UserTable.id, password: UserTable.password })
|
|
42
37
|
.from(UserTable)
|
|
43
38
|
.where(eq(UserTable.email, email));
|
|
44
39
|
return user;
|
|
45
40
|
},
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
// Create a new user with the hashed password
|
|
42
|
+
createUser: async (data: { email: string; password: string; name: string }) => {
|
|
43
|
+
const [user] = await db
|
|
44
|
+
.insert(UserTable)
|
|
45
|
+
.values(data)
|
|
46
|
+
.returning({ id: UserTable.id, password: UserTable.password });
|
|
47
|
+
return user;
|
|
48
|
+
},
|
|
49
|
+
// Optional: callback after successful sign-up
|
|
50
|
+
onSignUp: async (userId, body: { orgTitle?: string }) => {
|
|
51
|
+
if (body.orgTitle && userId) {
|
|
52
|
+
await createOrg(userId, { title: body.orgTitle });
|
|
53
|
+
}
|
|
49
54
|
},
|
|
50
55
|
});
|
|
51
56
|
```
|
|
52
57
|
|
|
53
|
-
|
|
58
|
+
The returned route handlers map to:
|
|
59
|
+
|
|
60
|
+
| Handler | HTTP Method | Purpose |
|
|
61
|
+
| -------- | ----------- | ----------------------------------------- |
|
|
62
|
+
| `GET` | GET | Refresh tokens (exchange refresh cookie) |
|
|
63
|
+
| `POST` | POST | Sign up (create user, return tokens) |
|
|
64
|
+
| `PUT` | PUT | Login (verify credentials, return tokens) |
|
|
65
|
+
| `DELETE` | DELETE | Logout (clear refresh cookie) |
|
|
54
66
|
|
|
55
67
|
### Client Setup
|
|
56
68
|
|
|
57
|
-
Wrap your application with `AuthWrapper` in your root layout
|
|
69
|
+
Wrap your application with `AuthWrapper` in your root layout. This fetches the access token on mount and provides it to all auth hooks via React context.
|
|
58
70
|
|
|
59
|
-
```
|
|
60
|
-
//
|
|
61
|
-
"use client";
|
|
71
|
+
```tsx
|
|
72
|
+
// app/layout.tsx
|
|
62
73
|
import { AuthWrapper } from "naystack/auth/email/client";
|
|
74
|
+
import { ApolloWrapper } from "naystack/graphql/client";
|
|
63
75
|
|
|
76
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
77
|
+
return (
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<body>
|
|
80
|
+
<AuthWrapper>
|
|
81
|
+
<ApolloWrapper>{children}</ApolloWrapper>
|
|
82
|
+
</AuthWrapper>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
64
87
|
```
|
|
65
88
|
|
|
66
|
-
### Frontend
|
|
89
|
+
### Frontend Hooks
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
#### `useToken()`
|
|
92
|
+
|
|
93
|
+
Returns the current JWT access token (or `null` if not loaded / logged out). Use it for conditional rendering or passing to custom fetch calls.
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import { useToken } from "naystack/auth/email/client";
|
|
97
|
+
|
|
98
|
+
export default function Home() {
|
|
99
|
+
const token = useToken();
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Link href={token ? "/dashboard" : "/signup"}>
|
|
103
|
+
<button>{token ? "Dashboard" : "Get Started"}</button>
|
|
104
|
+
</Link>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
#### `useSignUp()`
|
|
110
|
+
|
|
111
|
+
Returns a function that registers a new user. Sends a POST to the auth endpoint. Returns `null` on success, or the error message string on failure.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { useSignUp } from "naystack/auth/email/client";
|
|
115
|
+
|
|
116
|
+
function SignUpForm() {
|
|
117
|
+
const signUp = useSignUp();
|
|
118
|
+
|
|
119
|
+
const handleSubmit = async (data: { name: string; email: string; password: string }) => {
|
|
120
|
+
const error = await signUp(data);
|
|
121
|
+
if (error) {
|
|
122
|
+
setMessage(error);
|
|
123
|
+
} else {
|
|
124
|
+
router.replace("/dashboard");
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### `useLogin()`
|
|
70
131
|
|
|
132
|
+
Returns a function that logs the user in. Sends a PUT to the auth endpoint. Returns `null` on success, or the error message string on failure.
|
|
71
133
|
|
|
72
|
-
|
|
134
|
+
```tsx
|
|
135
|
+
import { useLogin } from "naystack/auth/email/client";
|
|
136
|
+
|
|
137
|
+
function LoginForm() {
|
|
73
138
|
const login = useLogin();
|
|
74
|
-
|
|
139
|
+
|
|
140
|
+
const handleSubmit = async (data: { email: string; password: string }) => {
|
|
141
|
+
const error = await login(data);
|
|
142
|
+
if (error) {
|
|
143
|
+
form.setError("password", { message: error });
|
|
144
|
+
} else {
|
|
145
|
+
router.replace("/dashboard");
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### `useLogout()`
|
|
152
|
+
|
|
153
|
+
Returns a function that logs the user out. Clears the token immediately and sends DELETE to the auth endpoint.
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
import { useLogout } from "naystack/auth/email/client";
|
|
157
|
+
|
|
158
|
+
function LogoutButton() {
|
|
75
159
|
const logout = useLogout();
|
|
76
|
-
const token = useToken(); // Get current JWT token
|
|
77
160
|
|
|
78
|
-
|
|
161
|
+
return (
|
|
162
|
+
<button onClick={() => { logout(); router.push("/login"); }}>
|
|
163
|
+
Log out
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Server-side Auth Helpers
|
|
170
|
+
|
|
171
|
+
#### `getContext(req)`
|
|
172
|
+
|
|
173
|
+
Extracts the auth context from a `NextRequest`. Reads either the `Authorization: Bearer <token>` header or the refresh cookie. Use it in API routes outside of GraphQL.
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import { getContext } from "naystack/auth";
|
|
177
|
+
|
|
178
|
+
export const POST = async (req: NextRequest) => {
|
|
179
|
+
const ctx = getContext(req);
|
|
180
|
+
if (!ctx?.userId) return new NextResponse("Unauthorized", { status: 401 });
|
|
181
|
+
|
|
182
|
+
// ctx.userId is available for authenticated operations
|
|
183
|
+
const chats = await db.select().from(ChatTable).where(eq(ChatTable.userId, ctx.userId));
|
|
184
|
+
return NextResponse.json(chats);
|
|
185
|
+
};
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### `getRefreshToken()`
|
|
189
|
+
|
|
190
|
+
Server-side function to read the refresh token from cookies. Useful in Server Components and layouts to check if the user is logged in.
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { getRefreshToken } from "naystack/auth";
|
|
194
|
+
import { redirect } from "next/navigation";
|
|
195
|
+
|
|
196
|
+
export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
|
|
197
|
+
const token = await getRefreshToken();
|
|
198
|
+
if (!token) return redirect("/login");
|
|
199
|
+
return <div>{children}</div>;
|
|
79
200
|
}
|
|
80
201
|
```
|
|
81
202
|
|
|
203
|
+
#### `checkAuthStatus(redirectURL?)`
|
|
204
|
+
|
|
205
|
+
Checks if the current request has a valid refresh cookie. Optionally redirects to the given URL if not authorized.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { checkAuthStatus } from "naystack/auth";
|
|
209
|
+
|
|
210
|
+
// In a Server Component:
|
|
211
|
+
await checkAuthStatus("/login"); // Redirects to /login if not authorized
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Google OAuth
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { initGoogleAuth } from "naystack/auth";
|
|
218
|
+
|
|
219
|
+
export const { GET } = initGoogleAuth({
|
|
220
|
+
getUserIdFromEmail: async (googleUser) => {
|
|
221
|
+
// Find or create user by Google email
|
|
222
|
+
return findOrCreateUserByEmail(googleUser.email!);
|
|
223
|
+
},
|
|
224
|
+
redirectURL: "/dashboard",
|
|
225
|
+
errorRedirectURL: "/login",
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Instagram OAuth
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { initInstagramAuth } from "naystack/auth";
|
|
233
|
+
|
|
234
|
+
export const { GET, getRefreshedAccessToken } = initInstagramAuth({
|
|
235
|
+
onUser: async (igUser, appUserId, accessToken) => {
|
|
236
|
+
await saveInstagramUser(appUserId, igUser, accessToken);
|
|
237
|
+
},
|
|
238
|
+
successRedirectURL: "/dashboard",
|
|
239
|
+
errorRedirectURL: "/login",
|
|
240
|
+
refreshKey: process.env.REFRESH_KEY!,
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
82
244
|
---
|
|
83
245
|
|
|
84
246
|
## 2. GraphQL
|
|
85
247
|
|
|
86
|
-
Naystack
|
|
248
|
+
Naystack provides a type-safe GraphQL layer built on `type-graphql` and `Apollo Server`. Define resolvers as plain functions and let the library generate the schema.
|
|
87
249
|
|
|
88
|
-
###
|
|
250
|
+
### Defining Queries and Mutations
|
|
251
|
+
|
|
252
|
+
Use `query()` to define a resolver. It returns an object with the resolver function, plus `.call()` and `.authCall()` for direct server-side invocation (e.g. in Server Components).
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
// app/api/(graphql)/User/resolvers/get-current-user.ts
|
|
256
|
+
import { query } from "naystack/graphql";
|
|
257
|
+
|
|
258
|
+
export default query(
|
|
259
|
+
async (ctx) => {
|
|
260
|
+
if (!ctx.userId) return null;
|
|
261
|
+
const [user] = await db
|
|
262
|
+
.select()
|
|
263
|
+
.from(UserTable)
|
|
264
|
+
.where(eq(UserTable.id, ctx.userId));
|
|
265
|
+
return user || null;
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
output: User, // GraphQL return type (type-graphql class)
|
|
269
|
+
outputOptions: { nullable: true }, // Return type is nullable
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**With input and authorization:**
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
// app/api/(graphql)/Feedback/resolvers/submit-feedback.ts
|
|
278
|
+
import { query } from "naystack/graphql";
|
|
279
|
+
|
|
280
|
+
export default query(
|
|
281
|
+
async (ctx, input: SubmitFeedbackInput) => {
|
|
282
|
+
await db.insert(FeedbackTable).values({
|
|
283
|
+
userId: ctx.userId, // guaranteed non-null when authorized: true
|
|
284
|
+
score: input.score,
|
|
285
|
+
text: input.text,
|
|
286
|
+
});
|
|
287
|
+
return true;
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
output: Boolean,
|
|
291
|
+
input: SubmitFeedbackInput, // GraphQL input type (type-graphql @InputType class)
|
|
292
|
+
authorized: true, // Requires authenticated user (ctx.userId non-null)
|
|
293
|
+
mutation: true, // Registers as a Mutation (default is Query)
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Defining Field Resolvers
|
|
299
|
+
|
|
300
|
+
Use `field()` to define resolvers for computed fields on a parent type. The first argument is the parent object.
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// app/api/(graphql)/Property/resolvers/seller-field.ts
|
|
304
|
+
import { field } from "naystack/graphql";
|
|
305
|
+
|
|
306
|
+
export default field(
|
|
307
|
+
async (property: PropertyDB) => {
|
|
308
|
+
if (!property.sellerId) return null;
|
|
309
|
+
const [seller] = await db
|
|
310
|
+
.select()
|
|
311
|
+
.from(ContactTable)
|
|
312
|
+
.where(eq(ContactTable.id, property.sellerId));
|
|
313
|
+
return seller || null;
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
output: ContactGQL,
|
|
317
|
+
outputOptions: { nullable: true },
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
```
|
|
89
321
|
|
|
90
|
-
|
|
322
|
+
### Registering Resolvers
|
|
323
|
+
|
|
324
|
+
Use `QueryLibrary()` for queries/mutations and `FieldLibrary()` for field resolvers. Pass the result to `initGraphQLServer`.
|
|
91
325
|
|
|
92
326
|
```typescript
|
|
327
|
+
// app/api/(graphql)/User/graphql.ts
|
|
328
|
+
import { QueryLibrary, FieldLibrary } from "naystack/graphql";
|
|
329
|
+
import getCurrentUser from "./resolvers/get-current-user";
|
|
330
|
+
import onboardUser from "./resolvers/onboard-user";
|
|
331
|
+
import updateUser from "./resolvers/update-user";
|
|
332
|
+
import organizations from "./resolvers/organizations-field";
|
|
333
|
+
import { User } from "./types";
|
|
334
|
+
|
|
335
|
+
// Each key becomes a Query or Mutation field name in the schema
|
|
336
|
+
export const UserResolvers = QueryLibrary({
|
|
337
|
+
getCurrentUser,
|
|
338
|
+
onboardUser,
|
|
339
|
+
updateUser,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Each key becomes a field resolver on the User type
|
|
343
|
+
export const UserFieldResolvers = FieldLibrary<UserDB>(User, {
|
|
344
|
+
organizations,
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Initializing the GraphQL Server
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
// app/api/(graphql)/route.ts
|
|
93
352
|
import { initGraphQLServer } from "naystack/graphql";
|
|
94
|
-
import {
|
|
95
|
-
import {
|
|
353
|
+
import { UserResolvers, UserFieldResolvers } from "./User/graphql";
|
|
354
|
+
import { ChatResolvers } from "./Chat/graphql";
|
|
355
|
+
import { FeedbackResolvers } from "./Feedback/graphql";
|
|
96
356
|
|
|
97
357
|
export const { GET, POST } = await initGraphQLServer({
|
|
98
|
-
|
|
99
|
-
resolvers: [UserResolvers],
|
|
358
|
+
resolvers: [UserResolvers, UserFieldResolvers, ChatResolvers, FeedbackResolvers],
|
|
100
359
|
});
|
|
101
360
|
```
|
|
102
361
|
|
|
103
|
-
|
|
362
|
+
The `getContext` function is built in — it reads the `Authorization` header or refresh cookie automatically. Pass a custom `getContext` if you need to override it.
|
|
363
|
+
|
|
364
|
+
### Throwing Errors
|
|
365
|
+
|
|
366
|
+
Use `GQLError()` to throw structured GraphQL errors from resolvers:
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import { GQLError } from "naystack/graphql";
|
|
370
|
+
|
|
371
|
+
// In a resolver:
|
|
372
|
+
if (!input.email) throw GQLError(400); // "Please provide all required inputs"
|
|
373
|
+
if (!ctx.userId) throw GQLError(403); // "You are not allowed to perform this action"
|
|
374
|
+
if (!deal) throw GQLError(404, "Deal not found"); // Custom message
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Type Helper: `QueryResponseType`
|
|
378
|
+
|
|
379
|
+
Infer the return type of a query definition. Use it to type component props that receive query results.
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
import type { QueryResponseType } from "naystack/graphql";
|
|
383
|
+
import type getCurrentUser from "@/app/api/(graphql)/User/resolvers/get-current-user";
|
|
384
|
+
import type getDeal from "@/app/api/(graphql)/Deal/queries/get-deal";
|
|
385
|
+
|
|
386
|
+
interface DealDetailsProps {
|
|
387
|
+
user: QueryResponseType<typeof getCurrentUser>;
|
|
388
|
+
deal: QueryResponseType<typeof getDeal>;
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Server-Side Data Fetching
|
|
393
|
+
|
|
394
|
+
#### Direct calls with `.call()` / `.authCall()`
|
|
395
|
+
|
|
396
|
+
Every query definition has `.call()` (unauthenticated or based on `authorized` flag) and `.authCall()` (always reads the refresh cookie for auth). Use these in Server Components.
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
// In a Server Component:
|
|
400
|
+
const user = await getCurrentUser.authCall();
|
|
401
|
+
const planets = await getPlanets.authCall();
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
#### `Injector` Component
|
|
405
|
+
|
|
406
|
+
Wraps a client component and injects server-fetched data via Suspense. The component receives `{ data, loading }` as props.
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
// app/(dashboard)/chat/page.tsx
|
|
410
|
+
import { Injector } from "naystack/graphql/server";
|
|
411
|
+
import getCurrentUser from "@/app/api/(graphql)/User/resolvers/get-current-user";
|
|
412
|
+
import getChats from "@/app/api/(graphql)/Chat/resolvers/get-chats";
|
|
413
|
+
import { ChatWindow } from "./components/chat-window";
|
|
414
|
+
|
|
415
|
+
export default async function ChatPage() {
|
|
416
|
+
return (
|
|
417
|
+
<Injector
|
|
418
|
+
fetch={async () => {
|
|
419
|
+
const user = await getCurrentUser.authCall();
|
|
420
|
+
const chats = await getChats.authCall();
|
|
421
|
+
return { user, chats };
|
|
422
|
+
}}
|
|
423
|
+
Component={ChatWindow}
|
|
424
|
+
/>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
```
|
|
104
428
|
|
|
105
|
-
|
|
429
|
+
The `ChatWindow` component receives `{ data, loading }`:
|
|
430
|
+
|
|
431
|
+
```tsx
|
|
432
|
+
// components/chat-window.tsx
|
|
433
|
+
export function ChatWindow({ data, loading }: { data?: { user: ...; chats: ... }; loading: boolean }) {
|
|
434
|
+
if (loading) return <Spinner />;
|
|
435
|
+
return <div>{data?.user.name}'s chats: {data?.chats.length}</div>;
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
#### Server-side `query()` (from `naystack/graphql/server`)
|
|
440
|
+
|
|
441
|
+
Run a raw GraphQL query on the server using the registered Apollo client. Cookies are sent automatically.
|
|
106
442
|
|
|
107
443
|
```typescript
|
|
108
|
-
// gql/server.ts
|
|
109
444
|
import { query } from "naystack/graphql/server";
|
|
110
445
|
|
|
111
|
-
|
|
112
|
-
|
|
446
|
+
const data = await query(GetUserDocument, {
|
|
447
|
+
variables: { id: userId },
|
|
448
|
+
revalidate: 60, // Cache for 60s (Next.js ISR)
|
|
449
|
+
tags: ["user"], // For on-demand revalidation
|
|
450
|
+
});
|
|
113
451
|
```
|
|
114
452
|
|
|
115
453
|
### Client Setup (Apollo)
|
|
116
454
|
|
|
117
|
-
|
|
455
|
+
Wrap your app with `ApolloWrapper` (inside `AuthWrapper`) so client components can use GraphQL hooks:
|
|
118
456
|
|
|
119
|
-
```
|
|
120
|
-
//
|
|
457
|
+
```tsx
|
|
458
|
+
// app/layout.tsx
|
|
459
|
+
import { AuthWrapper } from "naystack/auth/email/client";
|
|
121
460
|
import { ApolloWrapper } from "naystack/graphql/client";
|
|
122
461
|
|
|
462
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
463
|
+
return (
|
|
464
|
+
<html lang="en">
|
|
465
|
+
<body>
|
|
466
|
+
<AuthWrapper>
|
|
467
|
+
<ApolloWrapper>{children}</ApolloWrapper>
|
|
468
|
+
</AuthWrapper>
|
|
469
|
+
</body>
|
|
470
|
+
</html>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
123
473
|
```
|
|
124
474
|
|
|
125
|
-
###
|
|
475
|
+
### Client Hooks
|
|
126
476
|
|
|
127
|
-
|
|
128
|
-
|
|
477
|
+
#### `useAuthQuery(query, variables?)`
|
|
478
|
+
|
|
479
|
+
Hook to run a GraphQL query with the current user's token. Returns `[refetch, { data, loading, error }]`.
|
|
480
|
+
|
|
481
|
+
```tsx
|
|
482
|
+
import { useAuthQuery } from "naystack/graphql/client";
|
|
483
|
+
import { GET_SUMMARY } from "@/constants/graphql/queries";
|
|
484
|
+
|
|
485
|
+
function SummaryCard({ type }: { type: string }) {
|
|
486
|
+
const [getSummary, { loading, data }] = useAuthQuery(GET_SUMMARY);
|
|
487
|
+
|
|
488
|
+
const handleFetch = async () => {
|
|
489
|
+
const result = await getSummary({ type });
|
|
490
|
+
if (result.data?.getSummary) {
|
|
491
|
+
setSummary(result.data.getSummary);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
return <button onClick={handleFetch} disabled={loading}>Get Summary</button>;
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
#### `useAuthMutation(mutation, options?)`
|
|
129
500
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
501
|
+
Hook to run a GraphQL mutation with the current user's token. Returns `[mutate, { data, loading, error }]`.
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
import { useAuthMutation } from "naystack/graphql/client";
|
|
505
|
+
import { CREATE_DEAL } from "@/lib/gql/mutations";
|
|
506
|
+
|
|
507
|
+
function CreateDealModal({ propertyId }: { propertyId: number }) {
|
|
508
|
+
const [createDeal, { loading }] = useAuthMutation(CREATE_DEAL);
|
|
509
|
+
|
|
510
|
+
const onSubmit = async (values: FormFields) => {
|
|
511
|
+
const response = await createDeal({
|
|
512
|
+
propertyId,
|
|
513
|
+
share: Number(values.share),
|
|
514
|
+
targetProfit: Number(values.targetProfit),
|
|
515
|
+
});
|
|
516
|
+
const dealId = response.data?.createDeal;
|
|
517
|
+
if (dealId) router.push(`/deals/${dealId}`);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
|
|
133
521
|
}
|
|
134
522
|
```
|
|
135
523
|
|
|
@@ -137,87 +525,254 @@ function Profile() {
|
|
|
137
525
|
|
|
138
526
|
## 3. File Upload
|
|
139
527
|
|
|
140
|
-
Naystack simplifies AWS S3 file uploads with presigned URLs and client-side helpers.
|
|
528
|
+
Naystack simplifies AWS S3 file uploads with presigned URLs and client-side helpers. AWS credentials are read from environment variables automatically.
|
|
141
529
|
|
|
142
530
|
### Server Setup
|
|
143
531
|
|
|
144
532
|
```typescript
|
|
533
|
+
// app/api/(rest)/file/route.ts
|
|
145
534
|
import { setupFileUpload } from "naystack/file";
|
|
146
535
|
|
|
147
|
-
export const { PUT
|
|
148
|
-
|
|
149
|
-
bucket: process.env.AWS_BUCKET!,
|
|
150
|
-
awsKey: process.env.AWS_ACCESS_KEY_ID!,
|
|
151
|
-
awsSecret: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
152
|
-
keys: {
|
|
153
|
-
signing: process.env.SIGNING_KEY!,
|
|
154
|
-
refresh: process.env.REFRESH_KEY!,
|
|
155
|
-
},
|
|
536
|
+
export const { PUT } = setupFileUpload({
|
|
537
|
+
// Called after each successful upload. Return value is sent in the response as `onUploadResponse`.
|
|
156
538
|
onUpload: async ({ url, type, userId, data }) => {
|
|
157
|
-
|
|
158
|
-
|
|
539
|
+
if (type === "DealDocument" && url) {
|
|
540
|
+
const payload = data as { dealId: number; fileName: string; category: string };
|
|
541
|
+
const [row] = await db
|
|
542
|
+
.insert(DealDocumentsTable)
|
|
543
|
+
.values({ dealId: payload.dealId, fileURL: url, fileName: payload.fileName, category: payload.category })
|
|
544
|
+
.returning();
|
|
545
|
+
return row ?? {};
|
|
546
|
+
}
|
|
547
|
+
return {};
|
|
159
548
|
},
|
|
549
|
+
// Optional: customize the S3 key (defaults to UUID)
|
|
550
|
+
getKey: async ({ type, userId }) => `${type}/${userId}/${crypto.randomUUID()}`,
|
|
160
551
|
});
|
|
161
552
|
```
|
|
162
553
|
|
|
163
|
-
|
|
554
|
+
The `setupFileUpload` also returns server-side helpers:
|
|
164
555
|
|
|
165
|
-
|
|
166
|
-
|
|
556
|
+
- **`uploadFile(keys, { url?, blob? })`** — Upload a file from a URL or Blob to S3.
|
|
557
|
+
- **`deleteFile(url)`** — Delete a file by its full S3 URL.
|
|
558
|
+
- **`getUploadURL(keys)`** — Get a presigned PUT URL.
|
|
559
|
+
- **`getDownloadURL(keys)`** — Get the public download URL.
|
|
560
|
+
|
|
561
|
+
### Client Usage
|
|
167
562
|
|
|
168
|
-
|
|
169
|
-
|
|
563
|
+
```tsx
|
|
564
|
+
import { useFileUpload } from "naystack/file/client";
|
|
170
565
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
566
|
+
function FileUploader({ dealId }: { dealId: number }) {
|
|
567
|
+
const uploadFile = useFileUpload();
|
|
568
|
+
const [uploading, setUploading] = useState(false);
|
|
569
|
+
|
|
570
|
+
const handleUpload = async (file: File) => {
|
|
571
|
+
setUploading(true);
|
|
572
|
+
try {
|
|
573
|
+
const result = await uploadFile(file, "DealDocument", {
|
|
574
|
+
dealId,
|
|
575
|
+
fileName: file.name,
|
|
576
|
+
category: "Contract",
|
|
577
|
+
});
|
|
578
|
+
if (result?.url) {
|
|
579
|
+
console.log("Uploaded:", result.url);
|
|
580
|
+
router.refresh();
|
|
581
|
+
}
|
|
582
|
+
} finally {
|
|
583
|
+
setUploading(false);
|
|
584
|
+
}
|
|
174
585
|
};
|
|
175
|
-
|
|
586
|
+
|
|
587
|
+
return <input type="file" onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])} />;
|
|
176
588
|
}
|
|
177
589
|
```
|
|
178
590
|
|
|
179
591
|
---
|
|
180
592
|
|
|
181
|
-
## 4.
|
|
182
|
-
|
|
183
|
-
### Client Hooks
|
|
184
|
-
|
|
185
|
-
- `useVisibility(onVisible)`: Triggers a callback when an element enters the viewport.
|
|
186
|
-
- `useBreakpoint(query)`: Responsive media query hook.
|
|
593
|
+
## 4. Client Utilities
|
|
187
594
|
|
|
188
595
|
### SEO
|
|
189
596
|
|
|
190
|
-
The `setupSEO` utility
|
|
597
|
+
The `setupSEO` utility creates a metadata factory for Next.js. Call it once with your site defaults, then use the returned function per-page.
|
|
191
598
|
|
|
192
599
|
```typescript
|
|
600
|
+
// lib/utils/seo.ts
|
|
193
601
|
import { setupSEO } from "naystack/client";
|
|
194
602
|
|
|
195
|
-
export const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
themeColor: "#
|
|
603
|
+
export const getSEO = setupSEO({
|
|
604
|
+
title: "My App - Tagline",
|
|
605
|
+
description: "Description of my application.",
|
|
606
|
+
siteName: "My App",
|
|
607
|
+
themeColor: "#5b9364",
|
|
200
608
|
});
|
|
609
|
+
|
|
610
|
+
// In a page:
|
|
611
|
+
export const metadata = getSEO("Dashboard", "Your personalized dashboard");
|
|
612
|
+
// Produces: title = "Dashboard • My App", description = "Your personalized dashboard"
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### `useVisibility(onVisible?)`
|
|
616
|
+
|
|
617
|
+
Triggers a callback when a DOM element enters the viewport. Returns a ref to attach to the observed element.
|
|
618
|
+
|
|
619
|
+
```tsx
|
|
620
|
+
import { useVisibility } from "naystack/client";
|
|
621
|
+
|
|
622
|
+
function LazySection() {
|
|
623
|
+
const ref = useVisibility(() => loadMoreData());
|
|
624
|
+
return <section ref={ref}>...</section>;
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### `useBreakpoint(query)`
|
|
629
|
+
|
|
630
|
+
Responsive media query hook. Returns `true`/`false` or `null` during SSR.
|
|
631
|
+
|
|
632
|
+
```tsx
|
|
633
|
+
import { useBreakpoint } from "naystack/client";
|
|
634
|
+
|
|
635
|
+
function ResponsiveNav() {
|
|
636
|
+
const isMobile = useBreakpoint("(max-width: 639px)");
|
|
637
|
+
if (isMobile === null) return <Skeleton />;
|
|
638
|
+
return isMobile ? <MobileNav /> : <DesktopNav />;
|
|
639
|
+
}
|
|
201
640
|
```
|
|
202
641
|
|
|
203
|
-
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## 5. Social APIs
|
|
645
|
+
|
|
646
|
+
Simplified access to Instagram Graph API and Threads API.
|
|
647
|
+
|
|
648
|
+
### Instagram
|
|
649
|
+
|
|
650
|
+
```typescript
|
|
651
|
+
import {
|
|
652
|
+
getInstagramUser,
|
|
653
|
+
getInstagramMedia,
|
|
654
|
+
getInstagramConversations,
|
|
655
|
+
getInstagramConversation,
|
|
656
|
+
getInstagramMessage,
|
|
657
|
+
sendInstagramMessage,
|
|
658
|
+
setupInstagramWebhook,
|
|
659
|
+
} from "naystack/socials";
|
|
660
|
+
|
|
661
|
+
// Fetch the authenticated user's profile
|
|
662
|
+
const user = await getInstagramUser(accessToken);
|
|
663
|
+
// => { username: "johndoe", followers_count: 1234, media_count: 56 }
|
|
664
|
+
|
|
665
|
+
// Fetch recent media
|
|
666
|
+
const media = await getInstagramMedia(accessToken, undefined, 10);
|
|
667
|
+
// => { data: [{ like_count: 5, comments_count: 2, permalink: "..." }, ...] }
|
|
668
|
+
|
|
669
|
+
// Fetch conversations with pagination
|
|
670
|
+
const convos = await getInstagramConversations(accessToken, 25);
|
|
671
|
+
for (const convo of convos.data ?? []) {
|
|
672
|
+
console.log(convo.participants, convo.messages);
|
|
673
|
+
}
|
|
674
|
+
if (convos.fetchMore) {
|
|
675
|
+
const nextPage = await convos.fetchMore();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Send a message
|
|
679
|
+
await sendInstagramMessage(accessToken, recipientId, "Hello!");
|
|
680
|
+
|
|
681
|
+
// Webhook setup (app/api/webhooks/instagram/route.ts)
|
|
682
|
+
export const { GET, POST } = setupInstagramWebhook({
|
|
683
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
684
|
+
callback: async (type, value, id) => {
|
|
685
|
+
console.log("Webhook event:", type, value, id);
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
```
|
|
204
689
|
|
|
205
|
-
|
|
690
|
+
### Threads
|
|
206
691
|
|
|
207
692
|
```typescript
|
|
208
|
-
import {
|
|
693
|
+
import {
|
|
694
|
+
getThread,
|
|
695
|
+
getThreads,
|
|
696
|
+
getThreadsReplies,
|
|
697
|
+
createThreadsPost,
|
|
698
|
+
createThread,
|
|
699
|
+
setupThreadsWebhook,
|
|
700
|
+
} from "naystack/socials";
|
|
701
|
+
|
|
702
|
+
// Fetch user's threads
|
|
703
|
+
const threads = await getThreads(accessToken);
|
|
704
|
+
// => [{ text: "Hello world", permalink: "...", username: "johndoe" }]
|
|
705
|
+
|
|
706
|
+
// Create and publish a single post
|
|
707
|
+
const postId = await createThreadsPost(accessToken, "Hello from Naystack!");
|
|
708
|
+
|
|
709
|
+
// Create a thread (sequence of posts)
|
|
710
|
+
const firstPostId = await createThread(accessToken, [
|
|
711
|
+
"First post in thread",
|
|
712
|
+
"Second post (reply to first)",
|
|
713
|
+
"Third post (reply to second)",
|
|
714
|
+
]);
|
|
715
|
+
|
|
716
|
+
// Webhook setup (app/api/webhooks/threads/route.ts)
|
|
717
|
+
export const { GET, POST } = setupThreadsWebhook({
|
|
718
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
719
|
+
callback: async (field, value) => {
|
|
720
|
+
console.log("Threads event:", field, value);
|
|
721
|
+
return true; // Return false to respond with 500
|
|
722
|
+
},
|
|
723
|
+
});
|
|
209
724
|
```
|
|
210
725
|
|
|
211
726
|
---
|
|
212
727
|
|
|
213
|
-
##
|
|
728
|
+
## Environment Variables
|
|
729
|
+
|
|
730
|
+
Naystack reads configuration from environment variables. Set the ones you need based on which modules you use.
|
|
214
731
|
|
|
215
|
-
|
|
732
|
+
### Required (Core Auth)
|
|
733
|
+
|
|
734
|
+
```bash
|
|
216
735
|
SIGNING_KEY=your-jwt-signing-key
|
|
217
736
|
REFRESH_KEY=your-jwt-refresh-key
|
|
218
|
-
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### Required (Client-side endpoints)
|
|
740
|
+
|
|
741
|
+
```bash
|
|
742
|
+
NEXT_PUBLIC_EMAIL_AUTH_ENDPOINT=/api/email
|
|
743
|
+
NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphql
|
|
744
|
+
NEXT_PUBLIC_FILE_ENDPOINT=/api/file
|
|
745
|
+
NEXT_PUBLIC_BASE_URL=https://yourapp.com
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
### Google OAuth
|
|
749
|
+
|
|
750
|
+
```bash
|
|
751
|
+
GOOGLE_CLIENT_ID=your-google-client-id
|
|
752
|
+
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
|
753
|
+
NEXT_PUBLIC_GOOGLE_AUTH_ENDPOINT=/api/google
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### Instagram OAuth
|
|
757
|
+
|
|
758
|
+
```bash
|
|
759
|
+
INSTAGRAM_CLIENT_ID=your-instagram-client-id
|
|
760
|
+
INSTAGRAM_CLIENT_SECRET=your-instagram-client-secret
|
|
761
|
+
NEXT_PUBLIC_INSTAGRAM_AUTH_ENDPOINT=/api/instagram
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
### AWS S3 (File Upload)
|
|
765
|
+
|
|
766
|
+
```bash
|
|
219
767
|
AWS_REGION=us-east-1
|
|
220
768
|
AWS_BUCKET=your-bucket-name
|
|
221
|
-
AWS_ACCESS_KEY_ID=
|
|
222
|
-
|
|
769
|
+
AWS_ACCESS_KEY_ID=your-access-key-id
|
|
770
|
+
AWS_ACCESS_KEY_SECRET=your-secret-access-key
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
### Optional
|
|
774
|
+
|
|
775
|
+
```bash
|
|
776
|
+
TURNSTILE_KEY=cloudflare-turnstile-secret-key
|
|
777
|
+
NODE_ENV=production
|
|
223
778
|
```
|