@aravindc26/velu 0.13.5 → 0.13.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aravindc26/velu",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -141,15 +141,10 @@ function prefixMdxComponentLinks(
141
141
  export default async function PreviewPage({ params }: PageProps) {
142
142
  const { sessionId, slug } = await params;
143
143
 
144
- console.log(`[PREVIEW:page] START session=${sessionId} slug=${slug.join('/')}`);
145
-
146
144
  const src = await getSessionSource(sessionId);
147
145
  const page = src.getPage(slug);
148
146
 
149
- if (!page) {
150
- console.log(`[PREVIEW:page] Page NOT FOUND for slug=${slug.join('/')}`);
151
- notFound();
152
- }
147
+ if (!page) notFound();
153
148
 
154
149
  const pageDataRecord = page.data as unknown as Record<string, unknown>;
155
150
 
@@ -159,22 +154,16 @@ export default async function PreviewPage({ params }: PageProps) {
159
154
  let pageToc: any;
160
155
 
161
156
  if (typeof loadFn === 'function') {
162
- console.log(`[PREVIEW:page] Calling load() for slug=${slug.join('/')}`);
163
157
  const loaded = await loadFn();
164
158
  MDX = loaded.body;
165
159
  pageToc = loaded.toc;
166
- console.log(`[PREVIEW:page] load() returned MDX=${typeof MDX} toc=${typeof pageToc}`);
167
160
  } else {
168
161
  // Fallback: pre-compiled entry (shouldn't happen in preview mode, but safe)
169
- console.log(`[PREVIEW:page] No load() — using pre-compiled body for slug=${slug.join('/')}`);
170
162
  MDX = pageDataRecord.body as any;
171
163
  pageToc = pageDataRecord.toc as any;
172
164
  }
173
165
 
174
- if (typeof MDX !== 'function') {
175
- console.log(`[PREVIEW:page] MDX is not a function (type=${typeof MDX}), returning 404`);
176
- notFound();
177
- }
166
+ if (typeof MDX !== 'function') notFound();
178
167
 
179
168
  const configSource = loadSessionConfigSource(sessionId);
180
169
  const footerSocials = configSource ? getFooterSocials(configSource) : [];
@@ -186,7 +175,6 @@ export default async function PreviewPage({ params }: PageProps) {
186
175
  if (pageInfo?.fullPath) {
187
176
  try {
188
177
  effectiveMarkdown = readFileSync(pageInfo.fullPath, 'utf-8');
189
- console.log(`[PREVIEW:page] Read markdown from ${pageInfo.fullPath} (${effectiveMarkdown.length} chars, first 120: ${JSON.stringify(effectiveMarkdown.slice(0, 120))})`);
190
178
  } catch { /* file may not exist */ }
191
179
  }
192
180
 
@@ -1,16 +1,72 @@
1
1
  /**
2
2
  * Shared API authentication via PREVIEW_API_SECRET.
3
3
  */
4
+ import { createHmac, timingSafeEqual } from 'crypto';
4
5
  import { NextRequest } from 'next/server';
5
6
 
6
7
  const PREVIEW_API_SECRET = process.env.PREVIEW_API_SECRET || '';
7
8
 
9
+ export function getPreviewSecret(): string {
10
+ return PREVIEW_API_SECRET;
11
+ }
12
+
8
13
  export function verifyApiSecret(request: NextRequest): boolean {
9
14
  if (!PREVIEW_API_SECRET) return true; // No secret configured — allow all
10
15
  const header = request.headers.get('x-preview-secret') || '';
11
16
  return header === PREVIEW_API_SECRET;
12
17
  }
13
18
 
19
+ /**
20
+ * Verify an HMAC-signed preview token.
21
+ *
22
+ * Token format: `{base64url_hmac}.{sessionId}:{unix_expiry}`
23
+ *
24
+ * Returns `{ valid: true, expiry }` when the token is authentic, matches the
25
+ * expected session ID, and has not expired. Returns `{ valid: false, expiry: 0 }`
26
+ * otherwise.
27
+ */
28
+ export function verifyPreviewToken(
29
+ token: string,
30
+ expectedSessionId: string,
31
+ ): { valid: boolean; expiry: number } {
32
+ const fail = { valid: false, expiry: 0 };
33
+ if (!PREVIEW_API_SECRET) return { valid: true, expiry: 0 }; // No secret — allow all
34
+
35
+ const dotIdx = token.indexOf('.');
36
+ if (dotIdx === -1) return fail;
37
+
38
+ const sigB64 = token.slice(0, dotIdx);
39
+ const payload = token.slice(dotIdx + 1);
40
+
41
+ // Payload must be "{sessionId}:{expiry}"
42
+ const colonIdx = payload.indexOf(':');
43
+ if (colonIdx === -1) return fail;
44
+
45
+ const sessionId = payload.slice(0, colonIdx);
46
+ const expiryStr = payload.slice(colonIdx + 1);
47
+
48
+ if (sessionId !== expectedSessionId) return fail;
49
+
50
+ const expiry = parseInt(expiryStr, 10);
51
+ if (isNaN(expiry) || expiry < Math.floor(Date.now() / 1000)) return fail;
52
+
53
+ // Recompute HMAC and compare in constant time
54
+ const expectedSig = createHmac('sha256', PREVIEW_API_SECRET)
55
+ .update(payload)
56
+ .digest('base64url')
57
+ // Strip padding to match Python's rstrip(b"=")
58
+ .replace(/=+$/, '');
59
+
60
+ // Ensure both are the same length before timingSafeEqual
61
+ const sigBuf = Buffer.from(sigB64, 'utf8');
62
+ const expectedBuf = Buffer.from(expectedSig, 'utf8');
63
+ if (sigBuf.length !== expectedBuf.length) return fail;
64
+
65
+ if (!timingSafeEqual(sigBuf, expectedBuf)) return fail;
66
+
67
+ return { valid: true, expiry };
68
+ }
69
+
14
70
  export function unauthorizedResponse() {
15
71
  return Response.json({ error: 'Unauthorized' }, { status: 401 });
16
72
  }
@@ -755,10 +755,9 @@ export function syncSessionFile(
755
755
  const workspaceDir = join(WORKSPACE_DIR, sessionId);
756
756
  const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
757
757
 
758
- console.log(`[PREVIEW:syncFile] session=${sessionId} file=${filePath} workspace=${workspaceDir} output=${outputDir}`);
758
+ console.log(`[PREVIEW:syncFile] session=${sessionId} file=${filePath}`);
759
759
 
760
760
  if (filePath === PRIMARY_CONFIG_NAME || filePath === LEGACY_CONFIG_NAME) {
761
- console.log(`[PREVIEW:syncFile] Config file changed — regenerating all content`);
762
761
  generateSessionContent(sessionId);
763
762
  return { synced: true };
764
763
  }
@@ -780,13 +779,9 @@ export function syncSessionFile(
780
779
  srcPath = join(workspaceDir, `${stripped}.md`);
781
780
  }
782
781
  if (!existsSync(srcPath)) {
783
- console.log(`[PREVIEW:syncFile] Source file NOT FOUND at any candidate path for ${filePath}`);
784
782
  return { synced: false };
785
783
  }
786
784
 
787
- const srcContent = readFileSync(srcPath, 'utf-8');
788
- console.log(`[PREVIEW:syncFile] Source file: ${srcPath} (${srcContent.length} chars, first 120: ${JSON.stringify(srcContent.slice(0, 120))})`);
789
-
790
785
  try {
791
786
  const { config } = loadConfig(workspaceDir);
792
787
  const artifacts = buildArtifacts(config, workspaceDir);
@@ -796,22 +791,15 @@ export function syncSessionFile(
796
791
 
797
792
  if (mapping) {
798
793
  const destPath = join(outputDir, `${mapping.dest}.mdx`);
799
- console.log(`[PREVIEW:syncFile] Mapped: ${stripped} -> ${mapping.dest}, writing to ${destPath}`);
800
794
  processPage(srcPath, destPath, stripped, variables, sessionId);
801
- const written = readFileSync(destPath, 'utf-8');
802
- console.log(`[PREVIEW:syncFile] Written dest (${written.length} chars, first 120: ${JSON.stringify(written.slice(0, 120))})`);
803
795
  return { synced: true };
804
796
  }
805
- console.log(`[PREVIEW:syncFile] No mapping found for ${stripped} in pageMap (${artifacts.pageMap.length} entries)`);
806
- } catch (err) {
807
- console.log(`[PREVIEW:syncFile] Mapping lookup failed, falling through to direct copy:`, err instanceof Error ? err.message : err);
797
+ } catch {
798
+ // Fall through to direct copy
808
799
  }
809
800
 
810
801
  const destPath = join(outputDir, `${stripped}.mdx`);
811
- console.log(`[PREVIEW:syncFile] Direct copy: ${srcPath} -> ${destPath}`);
812
802
  processPage(srcPath, destPath, stripped, variables, sessionId);
813
- const written = readFileSync(destPath, 'utf-8');
814
- console.log(`[PREVIEW:syncFile] Written dest (${written.length} chars, first 120: ${JSON.stringify(written.slice(0, 120))})`);
815
803
  return { synced: true };
816
804
  }
817
805
 
@@ -9,7 +9,7 @@
9
9
  * Used only in preview mode (PREVIEW_MODE=true) — production builds still use
10
10
  * the standard `source.ts` with build-time collections.
11
11
  */
12
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
12
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
13
13
  import { join, relative } from 'node:path';
14
14
  import { loader } from 'fumadocs-core/source';
15
15
  import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
@@ -31,6 +31,30 @@ function log(tag: string, msg: string, data?: Record<string, unknown>) {
31
31
 
32
32
  // ── Cache ──────────────────────────────────────────────────────────────────
33
33
 
34
+ // File-based invalidation signal. Next.js bundles API routes and page routes
35
+ // into separate module instances, so in-memory invalidation from the sync/init
36
+ // route handler does NOT clear the cache seen by the page renderer. Instead,
37
+ // the invalidation writes a timestamp to a file on disk which both sides can see.
38
+ const INVALIDATION_DIR = join(PREVIEW_CONTENT_DIR, '.invalidation');
39
+
40
+ function getInvalidationPath(sessionId: string): string {
41
+ return join(INVALIDATION_DIR, `${sessionId}.stamp`);
42
+ }
43
+
44
+ function readInvalidationStamp(sessionId: string): number {
45
+ const p = getInvalidationPath(sessionId);
46
+ try {
47
+ return Number(readFileSync(p, 'utf-8').trim()) || 0;
48
+ } catch {
49
+ return 0;
50
+ }
51
+ }
52
+
53
+ function writeInvalidationStamp(sessionId: string): void {
54
+ mkdirSync(INVALIDATION_DIR, { recursive: true });
55
+ writeFileSync(getInvalidationPath(sessionId), String(Date.now()), 'utf-8');
56
+ }
57
+
34
58
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
59
  const sourceCache = new Map<string, { source: any; createdAt: number }>();
36
60
 
@@ -154,11 +178,17 @@ function scanContentDir(sessionDir: string): {
154
178
  export async function getSessionSource(sessionId: string) {
155
179
  const cached = sourceCache.get(sessionId);
156
180
  if (cached) {
157
- const age = Date.now() - cached.createdAt;
158
- log('source', `Cache HIT for session ${sessionId}`, { ageMs: age });
159
- return cached.source;
181
+ const stamp = readInvalidationStamp(sessionId);
182
+ if (stamp <= cached.createdAt) {
183
+ const age = Date.now() - cached.createdAt;
184
+ log('source', `Cache HIT for session ${sessionId}`, { ageMs: age });
185
+ return cached.source;
186
+ }
187
+ log('source', `Cache STALE for session ${sessionId} (stamp=${stamp} > created=${cached.createdAt}), rebuilding`);
188
+ sourceCache.delete(sessionId);
189
+ } else {
190
+ log('source', `Cache MISS for session ${sessionId}`);
160
191
  }
161
- log('source', `Cache MISS for session ${sessionId}`);
162
192
 
163
193
  const sessionDir = join(PREVIEW_CONTENT_DIR, sessionId);
164
194
  if (!existsSync(sessionDir)) {
@@ -176,15 +206,6 @@ export async function getSessionSource(sessionId: string) {
176
206
  log('source', `Scanned ${sessionDir}`, {
177
207
  entryCount: entries.length,
178
208
  metaFileCount: Object.keys(metaFiles).length,
179
- files: entries.map((e) => {
180
- const stat = statSync(e.info.fullPath, { throwIfNoEntry: false });
181
- return {
182
- path: e.info.path,
183
- title: e.data.title,
184
- sizeBytes: stat?.size,
185
- mtimeMs: stat?.mtimeMs,
186
- };
187
- }),
188
209
  });
189
210
 
190
211
  const collection = await dyn.docs('docs', sessionDir, metaFiles, entries);
@@ -222,12 +243,15 @@ export async function getSessionSource(sessionId: string) {
222
243
 
223
244
  /**
224
245
  * Invalidate the cached source for a session.
225
- * Call after content generation or sync so the next request re-scans.
246
+ * Writes a file-based timestamp so that ALL Next.js module instances
247
+ * (API routes AND page routes) see the invalidation, even though they
248
+ * run in separate bundles with separate in-memory Maps.
226
249
  */
227
250
  export function invalidateSessionSource(sessionId: string): void {
228
251
  const had = sourceCache.has(sessionId);
229
252
  sourceCache.delete(sessionId);
230
- log('invalidate', `session=${sessionId} hadCache=${had} remainingKeys=[${[...sourceCache.keys()].join(',')}]`);
253
+ writeInvalidationStamp(sessionId);
254
+ log('invalidate', `session=${sessionId} hadCache=${had} wroteStamp=true`);
231
255
  }
232
256
 
233
257
  /**
@@ -0,0 +1,91 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getPreviewSecret, verifyPreviewToken } from './lib/preview-auth';
3
+
4
+ /**
5
+ * Next.js middleware that enforces HMAC-signed token authentication for
6
+ * preview page routes.
7
+ *
8
+ * Flow:
9
+ * 1. Requests with `x-preview-secret` header pass through (server-to-server API calls).
10
+ * 2. Extract sessionId from the URL path (`/{sessionId}/...`).
11
+ * 3. If `?token=` query param is present: validate HMAC, set an HttpOnly cookie,
12
+ * redirect to the same URL without the token (keeps browser history clean).
13
+ * 4. If a valid `__velu_preview_{sessionId}` cookie exists: allow.
14
+ * 5. Otherwise: 403.
15
+ */
16
+ export function middleware(request: NextRequest) {
17
+ const secret = getPreviewSecret();
18
+
19
+ // No secret configured — allow everything (dev / local)
20
+ if (!secret) return NextResponse.next();
21
+
22
+ // Server-to-server API calls use the x-preview-secret header — skip page auth
23
+ if (request.headers.get('x-preview-secret')) {
24
+ return NextResponse.next();
25
+ }
26
+
27
+ const { pathname } = request.nextUrl;
28
+
29
+ // Extract sessionId: first path segment after leading slash
30
+ const segments = pathname.split('/').filter(Boolean);
31
+ if (segments.length === 0) return NextResponse.next();
32
+
33
+ // Skip Next.js internal and static paths
34
+ const first = segments[0];
35
+ if (first === '_next' || first === 'api') return NextResponse.next();
36
+
37
+ // sessionId should be numeric
38
+ const sessionId = first;
39
+ if (!/^\d+$/.test(sessionId)) return NextResponse.next();
40
+
41
+ // --- Token in query string: validate, set cookie, redirect to clean URL ---
42
+ const tokenParam = request.nextUrl.searchParams.get('token');
43
+ if (tokenParam) {
44
+ const { valid, expiry } = verifyPreviewToken(tokenParam, sessionId);
45
+ if (!valid) {
46
+ return new NextResponse('Forbidden', { status: 403 });
47
+ }
48
+
49
+ // Redirect to the same URL without the token
50
+ const cleanUrl = request.nextUrl.clone();
51
+ cleanUrl.searchParams.delete('token');
52
+ const response = NextResponse.redirect(cleanUrl);
53
+
54
+ // Compute remaining TTL for cookie maxAge
55
+ const now = Math.floor(Date.now() / 1000);
56
+ const maxAge = expiry > 0 ? expiry - now : 86400;
57
+
58
+ response.cookies.set(`__velu_preview_${sessionId}`, tokenParam, {
59
+ httpOnly: true,
60
+ secure: true,
61
+ sameSite: 'none', // Required for cross-origin iframe
62
+ path: `/${sessionId}`,
63
+ maxAge,
64
+ });
65
+
66
+ return response;
67
+ }
68
+
69
+ // --- Cookie: validate existing token stored in cookie ---
70
+ const cookie = request.cookies.get(`__velu_preview_${sessionId}`);
71
+ if (cookie) {
72
+ const { valid } = verifyPreviewToken(cookie.value, sessionId);
73
+ if (valid) return NextResponse.next();
74
+ }
75
+
76
+ return new NextResponse('Forbidden', { status: 403 });
77
+ }
78
+
79
+ // Use Node.js runtime so we can access the crypto module for HMAC verification
80
+ export const runtime = 'nodejs';
81
+
82
+ export const config = {
83
+ matcher: [
84
+ /*
85
+ * Match all request paths except:
86
+ * - _next (Next.js internals)
87
+ * - favicon.ico, robots.txt, sitemap.xml (static meta)
88
+ */
89
+ '/((?!_next|favicon\\.ico|robots\\.txt|sitemap\\.xml).*)',
90
+ ],
91
+ };