@archiva/archiva-nextjs 0.1.4 → 0.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @archiva/archiva-nextjs
2
2
 
3
- Next.js SDK for Archiva - Server Actions and Timeline Component
3
+ Next.js SDK for Archiva - Frontend Token Provider and Timeline Component
4
4
 
5
5
  ## Installation
6
6
 
@@ -12,7 +12,9 @@ pnpm add @archiva/archiva-nextjs
12
12
  yarn add @archiva/archiva-nextjs
13
13
  ```
14
14
 
15
- ## Setup
15
+ ## Quick Start
16
+
17
+ ### 1. Set Environment Variable
16
18
 
17
19
  Set the `ARCHIVA_SECRET_KEY` environment variable in your `.env.local` file:
18
20
 
@@ -20,152 +22,349 @@ Set the `ARCHIVA_SECRET_KEY` environment variable in your `.env.local` file:
20
22
  ARCHIVA_SECRET_KEY=pk_test_xxxxx
21
23
  ```
22
24
 
23
- ## Usage
25
+ **Important:** This should be a valid Archiva API key. The SDK uses this to mint short-lived frontend tokens securely on the server.
26
+
27
+ ### 2. Create Token Endpoint Route
24
28
 
25
- ### ArchivaProvider (Client Component)
29
+ Create a Next.js API route to handle frontend token requests. This route will be called by the SDK to fetch short-lived tokens.
26
30
 
27
- Wrap your application (or specific routes) with the ArchivaProvider component. The provider makes the API key available to child components via React context:
31
+ **File:** `app/api/archiva/frontend-token/route.ts`
28
32
 
29
33
  ```tsx
30
- import { ArchivaProvider } from '@archiva/archiva-nextjs';
34
+ import { GET } from '@archiva/archiva-nextjs/server';
31
35
 
32
- export default function Layout({ children }) {
33
- // API key can be passed as prop, or it will use ARCHIVA_SECRET_KEY env var
34
- return (
35
- <ArchivaProvider apiKey="pk_test_xxxxx">
36
- {children}
37
- </ArchivaProvider>
38
- );
39
- }
36
+ // Export the GET handler - that's it!
37
+ export { GET };
40
38
  ```
41
39
 
42
- **Note:** If you don't pass an `apiKey` prop, the provider will use the `ARCHIVA_SECRET_KEY` environment variable. The Timeline component and server actions will automatically use the API key from the provider context.
40
+ Or with custom configuration:
43
41
 
44
- ### Server Actions
42
+ ```tsx
43
+ import { createFrontendTokenRoute } from '@archiva/archiva-nextjs/server';
45
44
 
46
- #### loadEvents
45
+ export const GET = createFrontendTokenRoute({
46
+ apiBaseUrl: process.env.ARCHIVA_API_URL, // Optional: defaults to https://api.archiva.app
47
+ });
48
+ ```
49
+
50
+ ### 3. Wrap Your App with ArchivaProvider
51
+
52
+ Wrap your application layout with the `ArchivaProvider` component. This is a **server component** that validates your `ARCHIVA_SECRET_KEY` and provides the token management context to client components.
47
53
 
48
- Load audit events with filtering and pagination:
54
+ **File:** `app/layout.tsx`
49
55
 
50
56
  ```tsx
51
- import { loadEvents } from '@archiva/archiva-nextjs';
52
-
53
- // In a Server Component or Server Action
54
- const events = await loadEvents({
55
- entityId: 'entity_123',
56
- actorId: 'actor_456',
57
- entityType: 'invoice',
58
- limit: 25,
59
- cursor: 'optional_cursor',
60
- });
57
+ import { ArchivaProvider } from '@archiva/archiva-nextjs/react';
58
+
59
+ export default function RootLayout({
60
+ children,
61
+ }: {
62
+ children: React.ReactNode;
63
+ }) {
64
+ return (
65
+ <html lang="en">
66
+ <body>
67
+ <ArchivaProvider>
68
+ {children}
69
+ </ArchivaProvider>
70
+ </body>
71
+ </html>
72
+ );
73
+ }
61
74
  ```
62
75
 
63
- #### createEvent
76
+ **Important:** Always import `ArchivaProvider` from `@archiva/archiva-nextjs/react` (not from the root package). This ensures the import is server-safe and won't trigger RSC errors.
64
77
 
65
- Create a single audit event:
78
+ With custom configuration:
66
79
 
67
80
  ```tsx
68
- import { createEvent } from '@archiva/archiva-nextjs';
69
-
70
- const result = await createEvent({
71
- action: 'update',
72
- entityType: 'invoice',
73
- entityId: 'inv_123',
74
- actorType: 'user',
75
- actorId: 'usr_123',
76
- actorDisplay: 'John Doe',
77
- occurredAt: new Date().toISOString(),
78
- source: 'web',
79
- context: {
80
- requestId: 'req_123',
81
- },
82
- changes: [
83
- {
84
- op: 'set',
85
- path: 'status',
86
- before: 'draft',
87
- after: 'sent',
88
- },
89
- ],
90
- });
81
+ import { ArchivaProvider } from '@archiva/archiva-nextjs/react';
82
+
83
+ export default function RootLayout({
84
+ children,
85
+ }: {
86
+ children: React.ReactNode;
87
+ }) {
88
+ return (
89
+ <html lang="en">
90
+ <body>
91
+ <ArchivaProvider
92
+ apiBaseUrl="https://api.archiva.app"
93
+ tokenEndpoint="/api/archiva/frontend-token"
94
+ projectId="proj_123" // Optional: for project-scoped tokens
95
+ >
96
+ {children}
97
+ </ArchivaProvider>
98
+ </body>
99
+ </html>
100
+ );
101
+ }
91
102
  ```
92
103
 
93
- #### createEvents (Bulk)
104
+ ### 4. Use the Timeline Component
94
105
 
95
- Create multiple events at once:
106
+ Now you can use the `Timeline` component in any client component. It will automatically:
107
+ - Fetch short-lived frontend tokens
108
+ - Call the Archiva API directly (no proxy routes needed)
109
+ - Handle token refresh automatically
110
+ - Retry on 401/403 errors
111
+
112
+ **File:** `app/invoice/[id]/page.tsx`
96
113
 
97
114
  ```tsx
98
- import { createEvents } from '@archiva/archiva-nextjs';
115
+ 'use client';
116
+
117
+ import { Timeline } from '@archiva/archiva-nextjs/react/client';
99
118
 
100
- const result = await createEvents([
101
- {
102
- action: 'create',
103
- entityType: 'invoice',
104
- entityId: 'inv_123',
105
- // ... other fields
106
- },
107
- {
108
- action: 'update',
109
- entityType: 'invoice',
110
- entityId: 'inv_123',
111
- // ... other fields
112
- },
113
- ]);
119
+ export default function InvoicePage({ params }: { params: { id: string } }) {
120
+ return (
121
+ <div>
122
+ <h1>Invoice {params.id}</h1>
123
+ <Timeline
124
+ entityId={params.id}
125
+ entityType="invoice"
126
+ initialLimit={25}
127
+ />
128
+ </div>
129
+ );
130
+ }
114
131
  ```
115
132
 
116
- ### Timeline Component (Client Component)
133
+ **Note:** Client components like `Timeline` and `useArchiva` must be imported from `@archiva/archiva-nextjs/react/client` to ensure proper client/server code splitting.
117
134
 
118
- Display a timeline of audit events. The Timeline component automatically uses the API key from the ArchivaProvider context:
135
+ ## How It Works
136
+
137
+ The SDK implements a Clerk-style provider pattern with short-lived frontend tokens:
138
+
139
+ 1. **Server Component (`ArchivaProvider`)**: Validates `ARCHIVA_SECRET_KEY` exists (never exposes it to the client)
140
+ 2. **Client Component (`ArchivaProviderClient`)**: Manages token lifecycle:
141
+ - Fetches tokens from your `/api/archiva/frontend-token` route
142
+ - Caches tokens in memory
143
+ - Auto-refreshes 30 seconds before expiry
144
+ - Handles 401/403 errors with automatic retry
145
+ 3. **Timeline Component**: Uses tokens to call Archiva API directly
146
+
147
+ ### Token Flow
148
+
149
+ ```
150
+ Client Component → useArchiva().getToken()
151
+
152
+ ArchivaProviderClient checks cache
153
+
154
+ If expired/absent → GET /api/archiva/frontend-token
155
+
156
+ Your route → POST /v1/frontend-tokens (Archiva API)
157
+
158
+ Server mints JWT (90s expiry)
159
+
160
+ Token returned → Cached → Used for API calls
161
+ ```
162
+
163
+ ## Advanced Usage
164
+
165
+ ### Using the `useArchiva` Hook
166
+
167
+ Access the Archiva context directly in your components:
119
168
 
120
169
  ```tsx
121
170
  'use client';
122
171
 
123
- import { Timeline } from '@archiva/archiva-nextjs';
172
+ import { useArchiva } from '@archiva/archiva-nextjs/react/client';
173
+
174
+ export function CustomTimeline({ entityId }: { entityId: string }) {
175
+ const { apiBaseUrl, getToken, forceRefreshToken } = useArchiva();
176
+ const [events, setEvents] = React.useState([]);
177
+
178
+ React.useEffect(() => {
179
+ async function loadEvents() {
180
+ const token = await getToken();
181
+
182
+ const response = await fetch(`${apiBaseUrl}/api/events?entityId=${entityId}`, {
183
+ headers: {
184
+ Authorization: `Bearer ${token}`,
185
+ },
186
+ });
187
+
188
+ if (!response.ok) {
189
+ // SDK automatically handles 401/403 with retry
190
+ throw new Error('Failed to load events');
191
+ }
192
+
193
+ const data = await response.json();
194
+ setEvents(data.items);
195
+ }
196
+
197
+ loadEvents();
198
+ }, [entityId, apiBaseUrl, getToken]);
124
199
 
125
- export function InvoiceTimeline({ invoiceId }: { invoiceId: string }) {
126
200
  return (
127
- <Timeline
128
- entityId={invoiceId}
129
- entityType="invoice"
130
- initialLimit={25}
131
- />
201
+ <div>
202
+ {events.map((event) => (
203
+ <div key={event.id}>{event.action}</div>
204
+ ))}
205
+ </div>
132
206
  );
133
207
  }
134
208
  ```
135
209
 
136
- **Note:** The Timeline component must be used within an `ArchivaProvider`. It no longer requires an `apiKey` prop as it gets the API key from the provider context.
210
+ ### Manual Token Refresh
211
+
212
+ Force a token refresh if needed:
213
+
214
+ ```tsx
215
+ 'use client';
216
+
217
+ import { useArchiva } from '@archiva/archiva-nextjs/react/client';
218
+
219
+ export function RefreshButton() {
220
+ const { forceRefreshToken } = useArchiva();
221
+
222
+ const handleRefresh = async () => {
223
+ try {
224
+ const newToken = await forceRefreshToken();
225
+ console.log('Token refreshed:', newToken);
226
+ } catch (error) {
227
+ console.error('Failed to refresh token:', error);
228
+ }
229
+ };
230
+
231
+ return <button onClick={handleRefresh}>Refresh Token</button>;
232
+ }
233
+ ```
234
+
235
+ ### Project-Scoped Tokens
236
+
237
+ If you want tokens scoped to a specific project, pass `projectId` to the provider:
238
+
239
+ ```tsx
240
+ <ArchivaProvider projectId="proj_123">
241
+ {children}
242
+ </ArchivaProvider>
243
+ ```
244
+
245
+ Or pass it as a query parameter to your token endpoint:
246
+
247
+ ```tsx
248
+ // GET /api/archiva/frontend-token?projectId=proj_123
249
+ ```
137
250
 
138
251
  ## API Reference
139
252
 
140
- ### LoadEventsParams
253
+ ### ArchivaProvider Props
254
+
255
+ ```ts
256
+ type ArchivaProviderProps = {
257
+ children: ReactNode;
258
+ apiBaseUrl?: string; // Default: 'https://api.archiva.app'
259
+ tokenEndpoint?: string; // Default: '/api/archiva/frontend-token'
260
+ projectId?: string; // Optional: for project-scoped tokens
261
+ };
262
+ ```
263
+
264
+ ### Timeline Props
141
265
 
142
266
  ```ts
143
- type LoadEventsParams = {
267
+ type TimelineProps = {
144
268
  entityId?: string;
145
269
  actorId?: string;
146
270
  entityType?: string;
147
- limit?: number;
148
- cursor?: string;
271
+ initialLimit?: number; // Default: 25
272
+ className?: string;
273
+ emptyMessage?: string; // Default: 'No events yet.'
149
274
  };
150
275
  ```
151
276
 
152
- ### CreateEventInput
277
+ ### useArchiva Hook
153
278
 
154
279
  ```ts
155
- type CreateEventInput = {
156
- action: string;
157
- entityType: string;
158
- entityId: string;
159
- actorType?: string;
160
- actorId?: string;
161
- actorDisplay?: string;
162
- occurredAt?: string;
163
- source?: string;
164
- context?: Record<string, unknown>;
165
- changes?: EventChange[];
280
+ type ArchivaContextValue = {
281
+ apiBaseUrl: string;
282
+ getToken: () => Promise<string>;
283
+ forceRefreshToken: () => Promise<string>;
284
+ projectId?: string;
166
285
  };
167
286
  ```
168
287
 
288
+ ## Security
289
+
290
+ - **No secrets in client bundles**: `ARCHIVA_SECRET_KEY` is only used on the server
291
+ - **Short-lived tokens**: Tokens expire in 90 seconds
292
+ - **Automatic refresh**: Tokens refresh 30 seconds before expiry
293
+ - **Scope enforcement**: Tokens require `timeline:read` scope
294
+ - **Project scoping**: Tokens can be scoped to specific projects
295
+
296
+ ## Import Paths
297
+
298
+ The SDK provides explicit entrypoints to ensure proper server/client code splitting:
299
+
300
+ ### Server Components (e.g., `app/layout.tsx`)
301
+
302
+ ```tsx
303
+ // ✅ Correct - Server-safe import
304
+ import { ArchivaProvider } from '@archiva/archiva-nextjs/react';
305
+ ```
306
+
307
+ ### Client Components
308
+
309
+ ```tsx
310
+ 'use client';
311
+
312
+ // ✅ Correct - Client-only imports
313
+ import { Timeline, useArchiva } from '@archiva/archiva-nextjs/react/client';
314
+ ```
315
+
316
+ ### Server Routes (e.g., `app/api/archiva/frontend-token/route.ts`)
317
+
318
+ ```tsx
319
+ // ✅ Correct - Server utilities
320
+ import { GET } from '@archiva/archiva-nextjs/server';
321
+ ```
322
+
323
+ ### Why Separate Entrypoints?
324
+
325
+ Next.js App Router requires strict separation between server and client code. The SDK splits exports to prevent RSC errors:
326
+
327
+ - `@archiva/archiva-nextjs/react` - Server-safe exports (ArchivaProvider only)
328
+ - `@archiva/archiva-nextjs/react/client` - Client-only exports (Timeline, useArchiva)
329
+ - `@archiva/archiva-nextjs/server` - Server utilities (route handlers)
330
+
331
+ **Important:** Do NOT import client components from the root package or from `/react` in Server Components, as this will trigger RSC errors.
332
+
333
+ ## Backward Compatibility
334
+
335
+ Legacy exports are still available from the root package for backward compatibility, but they are deprecated:
336
+
337
+ ```tsx
338
+ // ⚠️ Legacy (deprecated - may cause RSC errors in Server Components)
339
+ import { ArchivaProvider, Timeline } from '@archiva/archiva-nextjs';
340
+ ```
341
+
342
+ For new projects, always use the explicit entrypoints shown above.
343
+
344
+ ## Troubleshooting
345
+
346
+ ### "ARCHIVA_SECRET_KEY not configured"
347
+
348
+ Make sure you've set `ARCHIVA_SECRET_KEY` in your `.env.local` file and restarted your Next.js dev server.
349
+
350
+ ### "useArchivaContext must be used within an ArchivaProvider"
351
+
352
+ Wrap your component tree with `<ArchivaProvider>`. The provider must be a server component in your layout.
353
+
354
+ ### Token endpoint returns 401
355
+
356
+ Verify that:
357
+ 1. `ARCHIVA_SECRET_KEY` is set correctly
358
+ 2. The API key is valid and active
359
+ 3. Your token endpoint route is correctly set up
360
+
361
+ ### Timeline shows "Error: Failed to fetch frontend token"
362
+
363
+ Check that:
364
+ 1. Your `/api/archiva/frontend-token` route exists
365
+ 2. The route handler is exported correctly
366
+ 3. `ARCHIVA_SECRET_KEY` is accessible to the route handler
367
+
169
368
  ## License
170
369
 
171
370
  MIT
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ import {
3
+ ArchivaContext,
4
+ ArchivaProviderClient,
5
+ useArchivaContext
6
+ } from "./chunk-H4TGL57C.mjs";
7
+ export {
8
+ ArchivaContext,
9
+ ArchivaProviderClient,
10
+ useArchivaContext
11
+ };
@@ -0,0 +1,136 @@
1
+ // src/react/internal/ArchivaProviderClient.tsx
2
+ import * as React from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ var ArchivaContext = React.createContext(void 0);
5
+ function ArchivaProviderClient({
6
+ children,
7
+ apiBaseUrl,
8
+ tokenEndpoint,
9
+ projectId
10
+ }) {
11
+ const [tokenCache, setTokenCache] = React.useState(null);
12
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
13
+ const refreshTimeoutRef = React.useRef(null);
14
+ React.useEffect(() => {
15
+ return () => {
16
+ if (refreshTimeoutRef.current) {
17
+ clearTimeout(refreshTimeoutRef.current);
18
+ }
19
+ };
20
+ }, []);
21
+ const fetchToken = React.useCallback(async () => {
22
+ const baseUrl = tokenEndpoint.startsWith("http") ? tokenEndpoint : typeof window !== "undefined" ? `${window.location.origin}${tokenEndpoint}` : `${apiBaseUrl}${tokenEndpoint}`;
23
+ const url = new URL(baseUrl);
24
+ if (projectId) {
25
+ url.searchParams.set("projectId", projectId);
26
+ }
27
+ const response = await fetch(url.toString(), {
28
+ method: "GET",
29
+ credentials: "include"
30
+ });
31
+ if (!response.ok) {
32
+ const error = await response.json().catch(() => ({ error: "Failed to fetch token" }));
33
+ throw new Error(error.error || "Failed to fetch frontend token");
34
+ }
35
+ const data = await response.json();
36
+ if (!data.token || !data.expiresAt) {
37
+ throw new Error("Invalid token response");
38
+ }
39
+ return data.token;
40
+ }, [tokenEndpoint, projectId, apiBaseUrl]);
41
+ const getToken = React.useCallback(async () => {
42
+ const now = Math.floor(Date.now() / 1e3);
43
+ if (tokenCache && tokenCache.expiresAt > now + 30) {
44
+ return tokenCache.token;
45
+ }
46
+ if (isRefreshing) {
47
+ await new Promise((resolve) => setTimeout(resolve, 100));
48
+ return getToken();
49
+ }
50
+ setIsRefreshing(true);
51
+ try {
52
+ const token = await fetchToken();
53
+ const expiresAt = getTokenExpiry(token);
54
+ if (!expiresAt) {
55
+ throw new Error("Failed to parse token expiry");
56
+ }
57
+ const newCache = { token, expiresAt };
58
+ setTokenCache(newCache);
59
+ const refreshIn = Math.max(0, expiresAt - now - 30) * 1e3;
60
+ if (refreshTimeoutRef.current) {
61
+ clearTimeout(refreshTimeoutRef.current);
62
+ }
63
+ refreshTimeoutRef.current = setTimeout(() => {
64
+ setTokenCache(null);
65
+ }, refreshIn);
66
+ return token;
67
+ } finally {
68
+ setIsRefreshing(false);
69
+ }
70
+ }, [tokenCache, isRefreshing, fetchToken]);
71
+ const forceRefreshToken = React.useCallback(async () => {
72
+ setTokenCache(null);
73
+ if (refreshTimeoutRef.current) {
74
+ clearTimeout(refreshTimeoutRef.current);
75
+ refreshTimeoutRef.current = null;
76
+ }
77
+ setIsRefreshing(true);
78
+ try {
79
+ const token = await fetchToken();
80
+ const expiresAt = getTokenExpiry(token);
81
+ if (!expiresAt) {
82
+ throw new Error("Failed to parse token expiry");
83
+ }
84
+ const newCache = { token, expiresAt };
85
+ setTokenCache(newCache);
86
+ const now = Math.floor(Date.now() / 1e3);
87
+ const refreshIn = Math.max(0, expiresAt - now - 30) * 1e3;
88
+ if (refreshTimeoutRef.current) {
89
+ clearTimeout(refreshTimeoutRef.current);
90
+ }
91
+ refreshTimeoutRef.current = setTimeout(() => {
92
+ setTokenCache(null);
93
+ }, refreshIn);
94
+ return token;
95
+ } finally {
96
+ setIsRefreshing(false);
97
+ }
98
+ }, [fetchToken]);
99
+ const value = React.useMemo(
100
+ () => ({
101
+ apiBaseUrl,
102
+ getToken,
103
+ forceRefreshToken,
104
+ projectId
105
+ }),
106
+ [apiBaseUrl, getToken, forceRefreshToken, projectId]
107
+ );
108
+ return /* @__PURE__ */ jsx(ArchivaContext.Provider, { value, children });
109
+ }
110
+ function useArchivaContext() {
111
+ const context = React.useContext(ArchivaContext);
112
+ if (context === void 0) {
113
+ throw new Error("useArchivaContext must be used within an ArchivaProvider");
114
+ }
115
+ return context;
116
+ }
117
+ function getTokenExpiry(token) {
118
+ try {
119
+ const parts = token.split(".");
120
+ if (parts.length !== 3) {
121
+ return null;
122
+ }
123
+ const payload = JSON.parse(
124
+ atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"))
125
+ );
126
+ return payload.exp;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ export {
133
+ ArchivaContext,
134
+ ArchivaProviderClient,
135
+ useArchivaContext
136
+ };
@@ -0,0 +1,41 @@
1
+ // src/react/ArchivaProvider.tsx
2
+ import "server-only";
3
+
4
+ // src/react/internal/ArchivaProviderWrapper.tsx
5
+ import { use } from "react";
6
+ import { jsx } from "react/jsx-runtime";
7
+ var ArchivaProviderClientPromise = import("./ArchivaProviderClient-FQAPZXQF.mjs").then(
8
+ (mod) => mod.ArchivaProviderClient
9
+ );
10
+ function ArchivaProviderWrapper(props) {
11
+ const ArchivaProviderClient = use(ArchivaProviderClientPromise);
12
+ return /* @__PURE__ */ jsx(ArchivaProviderClient, { ...props });
13
+ }
14
+
15
+ // src/react/ArchivaProvider.tsx
16
+ import { jsx as jsx2 } from "react/jsx-runtime";
17
+ function ArchivaProvider({
18
+ children,
19
+ apiBaseUrl = "https://api.archiva.app",
20
+ tokenEndpoint = "/api/archiva/frontend-token",
21
+ projectId
22
+ }) {
23
+ if (!process.env.ARCHIVA_SECRET_KEY) {
24
+ throw new Error(
25
+ "ARCHIVA_SECRET_KEY environment variable is required. Set it in your .env.local file."
26
+ );
27
+ }
28
+ return /* @__PURE__ */ jsx2(
29
+ ArchivaProviderWrapper,
30
+ {
31
+ apiBaseUrl,
32
+ tokenEndpoint,
33
+ projectId,
34
+ children
35
+ }
36
+ );
37
+ }
38
+
39
+ export {
40
+ ArchivaProvider
41
+ };