@usequota/nextjs 0.1.0

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 ADDED
@@ -0,0 +1,462 @@
1
+ # @usequota/nextjs
2
+
3
+ Next.js SDK for [Quota](https://quota.io) - AI credit wallet and multi-provider inference API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @usequota/nextjs
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### 1. Set up environment variables
14
+
15
+ ```env
16
+ # .env.local
17
+ QUOTA_CLIENT_ID=your_client_id
18
+ QUOTA_CLIENT_SECRET=your_client_secret
19
+ NEXT_PUBLIC_QUOTA_CLIENT_ID=your_client_id
20
+ ```
21
+
22
+ ### 2. Configure middleware
23
+
24
+ Create `middleware.ts` in your project root:
25
+
26
+ ```typescript
27
+ import { createQuotaMiddleware } from "@usequota/nextjs";
28
+
29
+ export const middleware = createQuotaMiddleware({
30
+ clientId: process.env.QUOTA_CLIENT_ID!,
31
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
32
+ });
33
+
34
+ export const config = {
35
+ matcher: "/api/quota/callback",
36
+ };
37
+ ```
38
+
39
+ ### 3. Add QuotaProvider
40
+
41
+ Wrap your app with the provider in `app/layout.tsx`:
42
+
43
+ ```tsx
44
+ import { QuotaProvider } from "@usequota/nextjs";
45
+
46
+ export default function RootLayout({ children }) {
47
+ return (
48
+ <html>
49
+ <body>
50
+ <QuotaProvider clientId={process.env.NEXT_PUBLIC_QUOTA_CLIENT_ID!}>
51
+ {children}
52
+ </QuotaProvider>
53
+ </body>
54
+ </html>
55
+ );
56
+ }
57
+ ```
58
+
59
+ ### 4. Create API routes
60
+
61
+ Create the following API routes:
62
+
63
+ **`app/api/quota/me/route.ts`** - Fetch user data:
64
+
65
+ ```typescript
66
+ import { getQuotaUser } from "@usequota/nextjs/server";
67
+
68
+ export async function GET() {
69
+ const user = await getQuotaUser({
70
+ clientId: process.env.QUOTA_CLIENT_ID!,
71
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
72
+ });
73
+
74
+ if (!user) {
75
+ return new Response("Unauthorized", { status: 401 });
76
+ }
77
+
78
+ return Response.json(user);
79
+ }
80
+ ```
81
+
82
+ **`app/api/quota/logout/route.ts`** - Handle logout:
83
+
84
+ ```typescript
85
+ import { clearQuotaAuth } from "@usequota/nextjs/server";
86
+
87
+ export async function POST() {
88
+ await clearQuotaAuth();
89
+ return Response.json({ success: true });
90
+ }
91
+ ```
92
+
93
+ ### 5. Use in components
94
+
95
+ ```tsx
96
+ "use client";
97
+
98
+ import { useQuota } from "@usequota/nextjs";
99
+
100
+ export function MyComponent() {
101
+ const { user, isLoading, login } = useQuota();
102
+
103
+ if (isLoading) return <div>Loading...</div>;
104
+ if (!user) return <button onClick={login}>Sign in with Quota</button>;
105
+
106
+ return (
107
+ <div>
108
+ <p>Welcome, {user.email}!</p>
109
+ <p>Balance: {user.balance} credits</p>
110
+ </div>
111
+ );
112
+ }
113
+ ```
114
+
115
+ ## Features
116
+
117
+ - **OAuth 2.0 Authentication** - Secure user authentication flow
118
+ - **Token Management** - Automatic token refresh and cookie management
119
+ - **React Hooks** - Convenient hooks for auth state and user data
120
+ - **Server-Side Utilities** - Functions for API routes and server components
121
+ - **TypeScript Support** - Full type safety with TypeScript
122
+ - **Hosted Mode** - Optional server-side token storage for enhanced security
123
+
124
+ ## API Reference
125
+
126
+ ### Client-Side
127
+
128
+ #### Hooks
129
+
130
+ - `useQuota()` - Access full Quota context
131
+ - `useQuotaUser()` - Get current user
132
+ - `useQuotaAuth()` - Auth state and actions
133
+ - `useQuotaBalance()` - User credit balance
134
+
135
+ #### Components
136
+
137
+ - `<QuotaProvider>` - React context provider
138
+
139
+ ### Server-Side
140
+
141
+ Import from `@usequota/nextjs/server`:
142
+
143
+ - `getQuotaUser(config)` - Get authenticated user
144
+ - `requireQuotaAuth(config)` - Require auth (redirects if not logged in)
145
+ - `getQuotaPackages(config?)` - Fetch available credit packages
146
+ - `createQuotaCheckout(config)` - Create Stripe checkout session
147
+ - `clearQuotaAuth(config?)` - Clear auth cookies
148
+ - `createQuotaRouteHandlers(config)` - Generate all 6 API route handlers in one call
149
+ - `withQuotaAuth(config, handler)` - Wrap a route handler with automatic Quota auth
150
+
151
+ #### Typed Errors
152
+
153
+ - `QuotaError` - Base error class (`code`, `statusCode`, `hint`)
154
+ - `QuotaInsufficientCreditsError` - 402, includes `balance` and `required`
155
+ - `QuotaNotConnectedError` - 401, user has no Quota account connected
156
+ - `QuotaTokenExpiredError` - 401, token expired and refresh failed
157
+ - `QuotaRateLimitError` - 429, includes `retryAfter` (seconds)
158
+
159
+ ### Middleware
160
+
161
+ - `createQuotaMiddleware(config)` - Create OAuth callback middleware
162
+
163
+ ## Storage Modes
164
+
165
+ ### Client Mode (Default)
166
+
167
+ Tokens are stored in httpOnly cookies:
168
+
169
+ ```typescript
170
+ export const middleware = createQuotaMiddleware({
171
+ clientId: process.env.QUOTA_CLIENT_ID!,
172
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
173
+ storageMode: "client", // default
174
+ });
175
+ ```
176
+
177
+ ### Hosted Mode
178
+
179
+ Tokens stored server-side by Quota (more secure):
180
+
181
+ ```typescript
182
+ export const middleware = createQuotaMiddleware({
183
+ clientId: process.env.QUOTA_CLIENT_ID!,
184
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
185
+ storageMode: "hosted",
186
+ getExternalUserId: async (request) => {
187
+ // Return your user's ID from your auth system
188
+ const session = await getSession(request);
189
+ return session.userId;
190
+ },
191
+ });
192
+ ```
193
+
194
+ ## Advanced Usage
195
+
196
+ ### Custom OAuth Flow
197
+
198
+ ```typescript
199
+ const { login } = useQuota();
200
+
201
+ // Trigger OAuth flow
202
+ login();
203
+ ```
204
+
205
+ ### Server Components
206
+
207
+ ```tsx
208
+ import { getQuotaUser } from "@usequota/nextjs/server";
209
+
210
+ export default async function DashboardPage() {
211
+ const user = await getQuotaUser({
212
+ clientId: process.env.QUOTA_CLIENT_ID!,
213
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
214
+ });
215
+
216
+ if (!user) {
217
+ redirect("/");
218
+ }
219
+
220
+ return <div>Welcome, {user.email}!</div>;
221
+ }
222
+ ```
223
+
224
+ ### Protected Routes
225
+
226
+ ```tsx
227
+ import { requireQuotaAuth } from "@usequota/nextjs/server";
228
+
229
+ export default async function ProtectedPage() {
230
+ // Automatically redirects to login if not authenticated
231
+ const user = await requireQuotaAuth({
232
+ clientId: process.env.QUOTA_CLIENT_ID!,
233
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
234
+ });
235
+
236
+ return <div>Protected content for {user.email}</div>;
237
+ }
238
+ ```
239
+
240
+ ### Credit Packages
241
+
242
+ ```tsx
243
+ "use client";
244
+
245
+ import { useState, useEffect } from "react";
246
+ import type { CreditPackage } from "@usequota/nextjs";
247
+
248
+ export function BuyCredits() {
249
+ const [packages, setPackages] = useState<CreditPackage[]>([]);
250
+
251
+ useEffect(() => {
252
+ fetch("/api/packages")
253
+ .then((res) => res.json())
254
+ .then(setPackages);
255
+ }, []);
256
+
257
+ const handleBuy = async (packageId: string) => {
258
+ const res = await fetch("/api/checkout", {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({ packageId }),
262
+ });
263
+ const { url } = await res.json();
264
+ window.location.href = url;
265
+ };
266
+
267
+ return (
268
+ <div>
269
+ {packages.map((pkg) => (
270
+ <button key={pkg.id} onClick={() => handleBuy(pkg.id)}>
271
+ Buy {pkg.credits} credits for {pkg.price_display}
272
+ </button>
273
+ ))}
274
+ </div>
275
+ );
276
+ }
277
+ ```
278
+
279
+ ### Webhook Verification
280
+
281
+ Quota can send webhooks to notify your application about events. The SDK provides utilities to verify webhook signatures and handle events securely.
282
+
283
+ #### Set up webhook endpoint
284
+
285
+ Add your webhook secret to `.env.local`:
286
+
287
+ ```env
288
+ QUOTA_WEBHOOK_SECRET=your_webhook_secret
289
+ ```
290
+
291
+ #### Create webhook handler
292
+
293
+ **`app/api/quota/webhook/route.ts`**:
294
+
295
+ ```typescript
296
+ import { createWebhookHandler } from "@usequota/nextjs";
297
+
298
+ export const POST = createWebhookHandler(process.env.QUOTA_WEBHOOK_SECRET!, {
299
+ "balance.low": async (event) => {
300
+ // Send email notification when user balance is low
301
+ await sendLowBalanceEmail(event.data.user_id, event.data.current_balance);
302
+ },
303
+ "user.connected": async (event) => {
304
+ // Track new user connection
305
+ await analytics.track("user_connected", event.data);
306
+ },
307
+ "usage.completed": async (event) => {
308
+ // Log usage for analytics
309
+ await logUsage(event.data);
310
+ },
311
+ });
312
+ ```
313
+
314
+ #### Webhook event types
315
+
316
+ Quota sends the following webhook events:
317
+
318
+ - `user.connected` - User completed OAuth connection
319
+ - `user.disconnected` - User disconnected their account
320
+ - `balance.updated` - User's credit balance changed
321
+ - `balance.low` - User's balance fell below threshold
322
+ - `usage.completed` - API request completed and billed
323
+
324
+ #### Manual signature verification
325
+
326
+ For custom implementations:
327
+
328
+ ```typescript
329
+ import { verifyWebhookSignature, parseWebhook } from "@usequota/nextjs";
330
+
331
+ export async function POST(req: Request) {
332
+ try {
333
+ // Option 1: Parse and verify in one step
334
+ const event = await parseWebhook(req, process.env.QUOTA_WEBHOOK_SECRET!);
335
+
336
+ // Option 2: Manual verification
337
+ const payload = await req.text();
338
+ const signature = req.headers.get("x-quota-signature")!;
339
+
340
+ const isValid = verifyWebhookSignature({
341
+ payload,
342
+ signature,
343
+ secret: process.env.QUOTA_WEBHOOK_SECRET!,
344
+ });
345
+
346
+ if (!isValid) {
347
+ return Response.json({ error: "Invalid signature" }, { status: 400 });
348
+ }
349
+
350
+ const event = JSON.parse(payload);
351
+
352
+ // Handle event...
353
+
354
+ return Response.json({ received: true });
355
+ } catch (error) {
356
+ return Response.json({ error: error.message }, { status: 400 });
357
+ }
358
+ }
359
+ ```
360
+
361
+ ## Route Handler Factory
362
+
363
+ `createQuotaRouteHandlers` generates all API route handlers for Quota integration in one call, replacing 6 separate route files with boilerplate.
364
+
365
+ ```typescript
366
+ // lib/quota.ts
367
+ import { createQuotaRouteHandlers } from "@usequota/nextjs/server";
368
+
369
+ export const {
370
+ authorize, // GET - initiates OAuth flow
371
+ callback, // GET - handles OAuth callback
372
+ status, // GET - returns connection status + balance
373
+ packages, // GET - returns credit packages
374
+ checkout, // POST - creates Stripe checkout session
375
+ disconnect, // POST - disconnects user's Quota account
376
+ } = createQuotaRouteHandlers({
377
+ clientId: process.env.QUOTA_CLIENT_ID!,
378
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
379
+ });
380
+ ```
381
+
382
+ Then create thin route files:
383
+
384
+ ```typescript
385
+ // app/api/quota/authorize/route.ts
386
+ export { authorize as GET } from "@/lib/quota";
387
+
388
+ // app/api/quota/callback/route.ts
389
+ export { callback as GET } from "@/lib/quota";
390
+
391
+ // app/api/quota/status/route.ts
392
+ export { status as GET } from "@/lib/quota";
393
+ ```
394
+
395
+ ## withQuotaAuth
396
+
397
+ Wraps a route handler with automatic Quota authentication, token refresh, and error handling:
398
+
399
+ ```typescript
400
+ // app/api/summarize/route.ts
401
+ import { withQuotaAuth } from "@usequota/nextjs/server";
402
+
403
+ export const POST = withQuotaAuth(
404
+ {
405
+ clientId: process.env.QUOTA_CLIENT_ID!,
406
+ clientSecret: process.env.QUOTA_CLIENT_SECRET!,
407
+ },
408
+ async (request, { user, accessToken }) => {
409
+ // user is guaranteed to exist
410
+ // accessToken can proxy requests through Quota
411
+ const response = await fetch(
412
+ "https://api.usequota.app/v1/chat/completions",
413
+ {
414
+ method: "POST",
415
+ headers: {
416
+ Authorization: `Bearer ${accessToken}`,
417
+ "Content-Type": "application/json",
418
+ },
419
+ body: JSON.stringify({
420
+ model: "gpt-4o-mini",
421
+ messages: [{ role: "user", content: "Hello" }],
422
+ }),
423
+ },
424
+ );
425
+ return Response.json(await response.json());
426
+ },
427
+ );
428
+ ```
429
+
430
+ ## Typed Errors
431
+
432
+ Handle specific failure modes with `instanceof` checks:
433
+
434
+ ```typescript
435
+ import {
436
+ QuotaInsufficientCreditsError,
437
+ QuotaNotConnectedError,
438
+ QuotaRateLimitError,
439
+ QuotaError,
440
+ } from "@usequota/nextjs/server";
441
+
442
+ try {
443
+ await callQuotaAPI();
444
+ } catch (e) {
445
+ if (e instanceof QuotaInsufficientCreditsError) {
446
+ console.log(e.balance, e.required); // current balance, credits needed
447
+ }
448
+ if (e instanceof QuotaNotConnectedError) {
449
+ // redirect to login
450
+ }
451
+ if (e instanceof QuotaRateLimitError) {
452
+ await delay(e.retryAfter * 1000); // seconds until retry
453
+ }
454
+ if (e instanceof QuotaError) {
455
+ console.log(e.code, e.statusCode, e.hint);
456
+ }
457
+ }
458
+ ```
459
+
460
+ ## License
461
+
462
+ MIT
@@ -0,0 +1,120 @@
1
+ // src/errors.ts
2
+ var QuotaError = class extends Error {
3
+ /** Machine-readable error code */
4
+ code;
5
+ /** HTTP status code associated with this error */
6
+ statusCode;
7
+ /** Optional hint for resolving the error */
8
+ hint;
9
+ constructor(message, code, statusCode, hint) {
10
+ super(message);
11
+ this.name = "QuotaError";
12
+ this.code = code;
13
+ this.statusCode = statusCode;
14
+ this.hint = hint;
15
+ Object.setPrototypeOf(this, new.target.prototype);
16
+ }
17
+ };
18
+ var QuotaInsufficientCreditsError = class extends QuotaError {
19
+ /** Current balance (if available) */
20
+ balance;
21
+ /** Credits required for the operation (if available) */
22
+ required;
23
+ constructor(message, options) {
24
+ super(
25
+ message ?? "Insufficient credits to complete this operation",
26
+ "insufficient_credits",
27
+ 402,
28
+ "Purchase more credits or reduce usage"
29
+ );
30
+ this.name = "QuotaInsufficientCreditsError";
31
+ this.balance = options?.balance;
32
+ this.required = options?.required;
33
+ Object.setPrototypeOf(this, new.target.prototype);
34
+ }
35
+ };
36
+ var QuotaNotConnectedError = class extends QuotaError {
37
+ constructor(message) {
38
+ super(
39
+ message ?? "User has not connected a Quota account",
40
+ "not_connected",
41
+ 401,
42
+ "Connect your Quota account to use this feature"
43
+ );
44
+ this.name = "QuotaNotConnectedError";
45
+ Object.setPrototypeOf(this, new.target.prototype);
46
+ }
47
+ };
48
+ var QuotaTokenExpiredError = class extends QuotaError {
49
+ constructor(message) {
50
+ super(
51
+ message ?? "Quota access token has expired and could not be refreshed",
52
+ "token_expired",
53
+ 401,
54
+ "Reconnect your Quota account"
55
+ );
56
+ this.name = "QuotaTokenExpiredError";
57
+ Object.setPrototypeOf(this, new.target.prototype);
58
+ }
59
+ };
60
+ var QuotaRateLimitError = class extends QuotaError {
61
+ /** Seconds until the rate limit resets */
62
+ retryAfter;
63
+ constructor(message, retryAfter) {
64
+ super(
65
+ message ?? "Rate limit exceeded",
66
+ "rate_limit_exceeded",
67
+ 429,
68
+ "Wait before retrying"
69
+ );
70
+ this.name = "QuotaRateLimitError";
71
+ this.retryAfter = retryAfter ?? 60;
72
+ Object.setPrototypeOf(this, new.target.prototype);
73
+ }
74
+ };
75
+ async function errorFromResponse(response) {
76
+ if (response.ok) {
77
+ return null;
78
+ }
79
+ let body;
80
+ try {
81
+ body = await response.json();
82
+ } catch {
83
+ }
84
+ const message = body?.error?.message ?? response.statusText;
85
+ const code = body?.error?.code;
86
+ switch (response.status) {
87
+ case 402: {
88
+ const opts = {};
89
+ if (body?.error?.balance !== void 0) opts.balance = body.error.balance;
90
+ if (body?.error?.required !== void 0)
91
+ opts.required = body.error.required;
92
+ return new QuotaInsufficientCreditsError(message, opts);
93
+ }
94
+ case 429: {
95
+ const parsed = parseInt(response.headers.get("retry-after") ?? "60", 10);
96
+ const retryAfter = Number.isNaN(parsed) ? 60 : parsed;
97
+ return new QuotaRateLimitError(message, retryAfter);
98
+ }
99
+ case 401: {
100
+ if (code === "not_connected") {
101
+ return new QuotaNotConnectedError(message);
102
+ }
103
+ if (code === "token_expired") {
104
+ return new QuotaTokenExpiredError(message);
105
+ }
106
+ return new QuotaTokenExpiredError(message);
107
+ }
108
+ default:
109
+ return new QuotaError(message, code ?? "unknown", response.status);
110
+ }
111
+ }
112
+
113
+ export {
114
+ QuotaError,
115
+ QuotaInsufficientCreditsError,
116
+ QuotaNotConnectedError,
117
+ QuotaTokenExpiredError,
118
+ QuotaRateLimitError,
119
+ errorFromResponse
120
+ };
@@ -0,0 +1,119 @@
1
+ // src/errors.ts
2
+ var QuotaError = class extends Error {
3
+ /** Machine-readable error code */
4
+ code;
5
+ /** HTTP status code associated with this error */
6
+ statusCode;
7
+ /** Optional hint for resolving the error */
8
+ hint;
9
+ constructor(message, code, statusCode, hint) {
10
+ super(message);
11
+ this.name = "QuotaError";
12
+ this.code = code;
13
+ this.statusCode = statusCode;
14
+ this.hint = hint;
15
+ Object.setPrototypeOf(this, new.target.prototype);
16
+ }
17
+ };
18
+ var QuotaInsufficientCreditsError = class extends QuotaError {
19
+ /** Current balance (if available) */
20
+ balance;
21
+ /** Credits required for the operation (if available) */
22
+ required;
23
+ constructor(message, options) {
24
+ super(
25
+ message ?? "Insufficient credits to complete this operation",
26
+ "insufficient_credits",
27
+ 402,
28
+ "Purchase more credits or reduce usage"
29
+ );
30
+ this.name = "QuotaInsufficientCreditsError";
31
+ this.balance = options?.balance;
32
+ this.required = options?.required;
33
+ Object.setPrototypeOf(this, new.target.prototype);
34
+ }
35
+ };
36
+ var QuotaNotConnectedError = class extends QuotaError {
37
+ constructor(message) {
38
+ super(
39
+ message ?? "User has not connected a Quota account",
40
+ "not_connected",
41
+ 401,
42
+ "Connect your Quota account to use this feature"
43
+ );
44
+ this.name = "QuotaNotConnectedError";
45
+ Object.setPrototypeOf(this, new.target.prototype);
46
+ }
47
+ };
48
+ var QuotaTokenExpiredError = class extends QuotaError {
49
+ constructor(message) {
50
+ super(
51
+ message ?? "Quota access token has expired and could not be refreshed",
52
+ "token_expired",
53
+ 401,
54
+ "Reconnect your Quota account"
55
+ );
56
+ this.name = "QuotaTokenExpiredError";
57
+ Object.setPrototypeOf(this, new.target.prototype);
58
+ }
59
+ };
60
+ var QuotaRateLimitError = class extends QuotaError {
61
+ /** Seconds until the rate limit resets */
62
+ retryAfter;
63
+ constructor(message, retryAfter) {
64
+ super(
65
+ message ?? "Rate limit exceeded",
66
+ "rate_limit_exceeded",
67
+ 429,
68
+ "Wait before retrying"
69
+ );
70
+ this.name = "QuotaRateLimitError";
71
+ this.retryAfter = retryAfter ?? 60;
72
+ Object.setPrototypeOf(this, new.target.prototype);
73
+ }
74
+ };
75
+ async function errorFromResponse(response) {
76
+ if (response.ok) {
77
+ return null;
78
+ }
79
+ let body;
80
+ try {
81
+ body = await response.json();
82
+ } catch {
83
+ }
84
+ const message = body?.error?.message ?? response.statusText;
85
+ const code = body?.error?.code;
86
+ switch (response.status) {
87
+ case 402: {
88
+ const opts = {};
89
+ if (body?.error?.balance !== void 0) opts.balance = body.error.balance;
90
+ if (body?.error?.required !== void 0)
91
+ opts.required = body.error.required;
92
+ return new QuotaInsufficientCreditsError(message, opts);
93
+ }
94
+ case 429: {
95
+ const retryAfter = parseInt(
96
+ response.headers.get("retry-after") ?? "60",
97
+ 10
98
+ );
99
+ return new QuotaRateLimitError(message, retryAfter);
100
+ }
101
+ case 401: {
102
+ if (code === "not_connected") {
103
+ return new QuotaNotConnectedError(message);
104
+ }
105
+ return new QuotaTokenExpiredError(message);
106
+ }
107
+ default:
108
+ return new QuotaError(message, code ?? "unknown", response.status);
109
+ }
110
+ }
111
+
112
+ export {
113
+ QuotaError,
114
+ QuotaInsufficientCreditsError,
115
+ QuotaNotConnectedError,
116
+ QuotaTokenExpiredError,
117
+ QuotaRateLimitError,
118
+ errorFromResponse
119
+ };