jamdesk 1.1.21 → 1.1.23

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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * POST /api/jd/unlock — password verification endpoint.
3
+ *
4
+ * Node runtime only — scrypt runs here and nowhere else in the enforcement
5
+ * path. Must NOT be edge runtime.
6
+ *
7
+ * Flow:
8
+ * 1. Validate inputs (slug, password fields)
9
+ * 2. Check rate limits (per-IP + per-slug backstop) — BEFORE scrypt
10
+ * 3. Fetch stored hash from Redis
11
+ * 4. Always run scrypt (DUMMY_HASH when no hash stored) to prevent timing oracles
12
+ * 5. On match: sign host-bound cookie, 303 to sanitized `from`
13
+ * 6. On mismatch: 303 to unlock page with error=1
14
+ */
15
+
16
+ export const runtime = 'nodejs';
17
+ export const dynamic = 'force-dynamic';
18
+
19
+ import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
20
+ import { promisify } from 'util';
21
+ import { redis } from '@/lib/redis';
22
+ import { sanitizeFrom } from '@/lib/sanitize-from';
23
+ import { VALID_SLUG_RE } from '@/lib/middleware-helpers';
24
+ import { signAuthCookie } from '@/shared/auth-cookie';
25
+
26
+ const scryptAsync = promisify(scrypt) as (
27
+ password: string,
28
+ salt: Buffer,
29
+ keyLen: number,
30
+ options: { N: number; r: number; p: number; maxmem?: number }
31
+ ) => Promise<Buffer>;
32
+
33
+ // Scrypt baseline — N=16384 is the OWASP baseline; r and p come from the
34
+ // decoded stored hash so we can verify hashes with different cost factors.
35
+ const SCRYPT_N = 16384;
36
+ const KEY_LEN = 64;
37
+
38
+ // Dummy hash cost MUST match production scrypt params exactly so that the
39
+ // dummy verify path is indistinguishable from a real verify via timing. A
40
+ // disparity in either direction (faster OR slower) gives a network-adjacent
41
+ // attacker a signal to distinguish "no password configured" from "password
42
+ // set, wrong guess". Keep these in sync with the dashboard callable's
43
+ // SCRYPT_R / SCRYPT_P in handlers/projectAuth.ts.
44
+ const DUMMY_N = SCRYPT_N;
45
+ const DUMMY_R = 8;
46
+ const DUMMY_P = 1;
47
+
48
+ // Rate limit thresholds
49
+ const IP_LIMIT = 10;
50
+ const SLUG_LIMIT = 100;
51
+ const WINDOW_SECONDS = 3600; // 1 hour
52
+
53
+ // Cookie max age: 30 days
54
+ const COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
55
+
56
+ // Pre-compute a dummy hash at module load time so we can always run scrypt
57
+ // even when no hash is stored, preventing timing oracles that reveal whether
58
+ // a project has password protection configured.
59
+ // Top-level await is fine in Next.js 16 Node runtime routes.
60
+ const DUMMY_HASH: string = await (async () => {
61
+ const salt = randomBytes(16);
62
+ const derived = await scryptAsync('dummy-password-do-not-use', salt, KEY_LEN, {
63
+ N: DUMMY_N,
64
+ r: DUMMY_R,
65
+ p: DUMMY_P,
66
+ maxmem: 128 * 1024 * 1024,
67
+ });
68
+ return `scrypt$${DUMMY_N}$${DUMMY_R}$${DUMMY_P}$${salt.toString('base64')}$${derived.toString('base64')}`;
69
+ })();
70
+
71
+ interface ScryptHash {
72
+ N: number;
73
+ r: number;
74
+ p: number;
75
+ salt: Buffer;
76
+ hash: Buffer;
77
+ }
78
+
79
+ /**
80
+ * Parse a stored hash string in the format:
81
+ * scrypt$N$r$p$salt_b64$hash_b64
82
+ *
83
+ * Returns null if the format is malformed or values are out of bounds.
84
+ */
85
+ function decodeHash(stored: string): ScryptHash | null {
86
+ try {
87
+ const parts = stored.split('$');
88
+ if (parts.length !== 6) return null;
89
+ const [prefix, nStr, rStr, pStr, saltB64, hashB64] = parts;
90
+ if (prefix !== 'scrypt') return null;
91
+
92
+ const N = parseInt(nStr, 10);
93
+ const r = parseInt(rStr, 10);
94
+ const p = parseInt(pStr, 10);
95
+
96
+ // Sanity-check values to prevent DoS via absurd cost factors
97
+ if (!Number.isSafeInteger(N) || N < 1024 || N > 2 ** 20) return null;
98
+ if (!Number.isSafeInteger(r) || r < 1 || r > 64) return null;
99
+ if (!Number.isSafeInteger(p) || p < 1 || p > 64) return null;
100
+ if (!saltB64 || !hashB64) return null;
101
+
102
+ const salt = Buffer.from(saltB64, 'base64');
103
+ const hash = Buffer.from(hashB64, 'base64');
104
+
105
+ if (salt.length === 0 || hash.length === 0) return null;
106
+
107
+ return { N, r, p, salt, hash };
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Run scrypt on `password` with params from `stored` and compare
115
+ * using timingSafeEqual. Returns false on any error (malformed hash, etc.).
116
+ * Always returns false without throwing.
117
+ */
118
+ async function verifyScrypt(password: string, stored: string): Promise<boolean> {
119
+ const params = decodeHash(stored);
120
+ if (!params) return false;
121
+
122
+ try {
123
+ // Raise maxmem ceiling to 128MB so future param upgrades (higher r, p)
124
+ // don't hit the default 32MB ceiling. Current OWASP-baseline hashes
125
+ // (r=8, p=1) need only ~16MB.
126
+ const derived = await scryptAsync(password, params.salt, params.hash.length, {
127
+ N: params.N,
128
+ r: params.r,
129
+ p: params.p,
130
+ maxmem: 128 * 1024 * 1024,
131
+ });
132
+
133
+ // timingSafeEqual requires same length — already guaranteed because we
134
+ // derive exactly params.hash.length bytes above.
135
+ return timingSafeEqual(derived, params.hash);
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Extract the trusted client IP from the request.
143
+ *
144
+ * Trust order: x-vercel-forwarded-for → x-real-ip
145
+ * NEVER use raw x-forwarded-for — it is attacker-controllable.
146
+ *
147
+ * x-vercel-forwarded-for contains only the outermost client IP as set by
148
+ * Vercel's edge network, making it safe for rate-limiting.
149
+ */
150
+ function getClientIp(req: Request): string {
151
+ const vff = req.headers.get('x-vercel-forwarded-for');
152
+ if (vff) {
153
+ // May be a comma-separated list if there are intermediate proxies —
154
+ // take the first (leftmost) address, which is the original client.
155
+ return vff.split(',')[0].trim();
156
+ }
157
+ const realIp = req.headers.get('x-real-ip');
158
+ if (realIp) return realIp.trim();
159
+ return 'unknown';
160
+ }
161
+
162
+ /**
163
+ * Get the request host for cookie host-binding.
164
+ *
165
+ * In hostAtDocs mode the ISR middleware injects x-jamdesk-forwarded-host
166
+ * with the customer's own domain (e.g. docs.acme.com). Fall back to the
167
+ * raw Host header otherwise (e.g. acme.jamdesk.app).
168
+ */
169
+ function getRequestHost(req: Request): string {
170
+ return (
171
+ req.headers.get('x-jamdesk-forwarded-host') ||
172
+ req.headers.get('host') ||
173
+ 'unknown'
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Increment a Redis rate-limit counter and set a TTL if this is the first
179
+ * increment. Returns the new count. Uses incr + expire (not SETEX) so we
180
+ * only reset the TTL on the first hit within a window.
181
+ *
182
+ * Fail-closed: returns Infinity if Redis is unavailable. Callers compare
183
+ * against their limit with `>` so Infinity always trips the guard and blocks
184
+ * the attempt. This means a Redis outage rate-limits EVERYONE (including
185
+ * legitimate users) rather than letting an attacker bypass rate limiting by
186
+ * disrupting Redis. Do NOT change the sentinel to 0 — that would fail-open.
187
+ */
188
+ async function rateLimitIncr(key: string): Promise<number> {
189
+ if (!redis) return Infinity;
190
+ try {
191
+ const count = await redis.incr(key);
192
+ if (count === 1) {
193
+ await redis.expire(key, WINDOW_SECONDS);
194
+ }
195
+ return count;
196
+ } catch (err) {
197
+ console.warn('[unlock] rate limit Redis error — failing closed', { key, err });
198
+ return Infinity;
199
+ }
200
+ }
201
+
202
+ interface StoredSecret {
203
+ hash: string;
204
+ version: number;
205
+ }
206
+
207
+ // Returns null only when Redis is unconfigured (local dev). Real errors
208
+ // propagate so POST can fail closed with 503 — matching resolveAuth.
209
+ async function fetchStoredSecret(slug: string): Promise<StoredSecret | null> {
210
+ if (!redis) return null;
211
+ return redis.get<StoredSecret>(`projectAuthSecret:${slug}`);
212
+ }
213
+
214
+ export async function POST(req: Request): Promise<Response> {
215
+ // Fail-closed: if the signing secret is missing, we cannot issue valid
216
+ // cookies. Return 503 rather than silently issuing unsigned tokens.
217
+ const secret = process.env.EDGE_AUTH_SECRET;
218
+ if (!secret) {
219
+ return new Response('Service temporarily unavailable', {
220
+ status: 503,
221
+ headers: { 'Retry-After': '30' },
222
+ });
223
+ }
224
+
225
+ // Parse form body
226
+ let formData: FormData;
227
+ try {
228
+ formData = await req.formData();
229
+ } catch {
230
+ return new Response('Bad Request: invalid form data', { status: 400 });
231
+ }
232
+
233
+ const password = formData.get('password');
234
+ const rawFrom = formData.get('from');
235
+
236
+ if (!password || typeof password !== 'string') {
237
+ return new Response('Bad Request: missing password', { status: 400 });
238
+ }
239
+ // Mirror the 256-char cap from the dashboard callable. Prevents a caller
240
+ // from forcing a very slow scrypt by submitting a multi-MB "password".
241
+ if (password.length > 256) {
242
+ return new Response('Bad Request: password too long', { status: 400 });
243
+ }
244
+
245
+ const slug = req.headers.get('x-project-slug');
246
+ if (!slug) {
247
+ return new Response('Bad Request: missing x-project-slug', { status: 400 });
248
+ }
249
+ // Defense-in-depth: middleware validates, but a malformed header would
250
+ // otherwise leak into `projectAuthSecret:${slug}`.
251
+ if (!VALID_SLUG_RE.test(slug)) {
252
+ return new Response('Bad Request: invalid project slug', { status: 400 });
253
+ }
254
+
255
+ const from = sanitizeFrom(typeof rawFrom === 'string' ? rawFrom : null);
256
+ const clientIp = getClientIp(req);
257
+
258
+ // Two-tier rate limiting — both checked BEFORE scrypt to avoid letting
259
+ // attackers use the endpoint as a free scrypt oracle. Run in parallel:
260
+ // every request increments both counters regardless of which one trips,
261
+ // so there's no ordering dependency and the wall-clock cost is one
262
+ // Upstash RTT instead of two.
263
+ const ipKey = `unlock:ip:${clientIp}`;
264
+ const slugKey = `unlock:slug:${slug}`;
265
+
266
+ const [ipCount, slugCount] = await Promise.all([
267
+ rateLimitIncr(ipKey),
268
+ rateLimitIncr(slugKey),
269
+ ]);
270
+ if (ipCount > IP_LIMIT || slugCount > SLUG_LIMIT) {
271
+ return new Response('Too Many Requests', {
272
+ status: 429,
273
+ headers: { 'Retry-After': String(WINDOW_SECONDS) },
274
+ });
275
+ }
276
+
277
+ let stored: StoredSecret | null;
278
+ try {
279
+ stored = await fetchStoredSecret(slug);
280
+ } catch (err) {
281
+ console.error('[unlock] Redis fetch failed — failing closed', {
282
+ slug,
283
+ error: (err as Error)?.message ?? String(err),
284
+ });
285
+ return new Response('Service temporarily unavailable', {
286
+ status: 503,
287
+ headers: { 'Retry-After': '30' },
288
+ });
289
+ }
290
+ const hashToVerify = stored?.hash ?? DUMMY_HASH;
291
+
292
+ // Always run scrypt — even with DUMMY_HASH when no password is set — to
293
+ // prevent timing-based detection of whether a project has auth configured.
294
+ const match = await verifyScrypt(password, hashToVerify);
295
+
296
+ if (!match || !stored) {
297
+ // Wrong password, no hash configured, or malformed hash.
298
+ // Redirect back to unlock page with error indicator.
299
+ const unlockUrl = `/jd/unlock?slug=${encodeURIComponent(slug)}&from=${encodeURIComponent(from)}&error=1`;
300
+ return new Response(null, {
301
+ status: 303,
302
+ headers: { Location: unlockUrl },
303
+ });
304
+ }
305
+
306
+ // Correct password — sign a host-bound cookie and redirect to destination.
307
+ const host = getRequestHost(req);
308
+ const expiresAt = Date.now() + COOKIE_MAX_AGE * 1000;
309
+
310
+ const cookieValue = await signAuthCookie(
311
+ { slug, host, version: stored.version, expiresAt },
312
+ secret
313
+ );
314
+
315
+ const cookieName = `jd_auth_${slug}`;
316
+ const cookieHeader = `${cookieName}=${cookieValue}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`;
317
+
318
+ return new Response(null, {
319
+ status: 303,
320
+ headers: {
321
+ Location: from,
322
+ 'Set-Cookie': cookieHeader,
323
+ },
324
+ });
325
+ }
@@ -1,5 +1,19 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* Import base shared styles */
4
+ @import "../themes/base.css";
5
+
6
+ /*
7
+ * Theme loading strategy:
8
+ * - Jam theme (default): loaded here via @import
9
+ * - Other themes: loaded dynamically via <style> tag in layout.tsx
10
+ *
11
+ * This means jam's CSS is always present, but jam-specific styles (like the
12
+ * background gradient) are scoped to body[data-theme="jam"] so they don't
13
+ * affect other themes.
14
+ */
15
+ @import "../themes/jam/variables.css";
16
+
3
17
  /*
4
18
  * Light/Dark mode image utilities
5
19
  * These utilities enable showing different images based on theme.
@@ -21,17 +35,3 @@ img.inline-block { display: inline-block !important; }
21
35
  .dark img.dark\:inline-block, .dark .dark\:inline-block { display: inline-block !important; }
22
36
 
23
37
  /* Shiki handles syntax highlighting via CSS variables - no theme import needed */
24
-
25
- /* Import base shared styles */
26
- @import "../themes/base.css";
27
-
28
- /*
29
- * Theme loading strategy:
30
- * - Jam theme (default): loaded here via @import
31
- * - Other themes: loaded dynamically via <style> tag in layout.tsx
32
- *
33
- * This means jam's CSS is always present, but jam-specific styles (like the
34
- * background gradient) are scoped to body[data-theme="jam"] so they don't
35
- * affect other themes.
36
- */
37
- @import "../themes/jam/variables.css";
@@ -263,11 +263,36 @@ export default async function RootLayout({
263
263
  }: {
264
264
  children: React.ReactNode;
265
265
  }) {
266
+ // Unlock-mode short-circuit: middleware sets x-jd-unlock-mode when
267
+ // rewriting to /jd/unlock so we skip docs chrome, analytics, and R2 config
268
+ // fetch. The unlock page owns its own visuals.
269
+ const headersList = await headers();
270
+ if (headersList.get('x-jd-unlock-mode') === '1') {
271
+ return (
272
+ <html lang="en">
273
+ <head>
274
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
275
+ <meta name="robots" content="noindex, nofollow" />
276
+ </head>
277
+ <body
278
+ style={{
279
+ margin: 0,
280
+ minHeight: '100vh',
281
+ fontFamily:
282
+ 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
283
+ backgroundColor: '#f7f7f8',
284
+ }}
285
+ >
286
+ {children}
287
+ </body>
288
+ </html>
289
+ );
290
+ }
291
+
266
292
  // Get config - from R2 in ISR mode, from filesystem in static mode
267
293
  let config: DocsConfig;
268
294
  let resolvedProjectSlug: string | null = null;
269
295
  if (isIsrMode()) {
270
- const headersList = await headers();
271
296
  const projectSlug = getProjectFromRequest(headersList);
272
297
  resolvedProjectSlug = projectSlug;
273
298
  const hostAtDocs = getHostAtDocs(headersList);
@@ -389,7 +414,7 @@ export default async function RootLayout({
389
414
  {config.integrations?.posthog && (
390
415
  <link rel="dns-prefetch" href={config.integrations.posthog.apiHost || "https://app.posthog.com"} />
391
416
  )}
392
- {(config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
417
+ {process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
393
418
  <link rel="dns-prefetch" href={(() => {
394
419
  try {
395
420
  return config.integrations!.plausible!.scriptUrl
@@ -504,8 +529,8 @@ export default async function RootLayout({
504
529
  {customCss && (
505
530
  <style dangerouslySetInnerHTML={{ __html: customCss }} />
506
531
  )}
507
- {/* Plausible Analytics */}
508
- {(config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
532
+ {/* Plausible Analytics — production only; dev injection would spam "Ignoring Event: localhost" */}
533
+ {process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
509
534
  <PlausibleScript
510
535
  domain={config.integrations.plausible.domain}
511
536
  server={config.integrations.plausible.server}
@@ -0,0 +1,109 @@
1
+ import { redis } from './redis';
2
+ import { matchPublicPath } from './glob-match';
3
+
4
+ export { matchPublicPath };
5
+
6
+ export interface ResolvedAuth {
7
+ hash: string;
8
+ version: number;
9
+ publicPaths: string[];
10
+ }
11
+
12
+ interface AuthSecret {
13
+ hash: string;
14
+ version: number;
15
+ }
16
+
17
+ interface AuthPublic {
18
+ enabled?: boolean;
19
+ publicPaths?: string[];
20
+ }
21
+
22
+ /**
23
+ * Resolve active password protection for a project.
24
+ *
25
+ * Truth table (both sides must agree for gating to be active):
26
+ * projectAuthPublic.enabled=true AND projectAuthSecret has a hash → gate
27
+ * projectAuthPublic missing OR enabled!==true → no gate (return null)
28
+ * projectAuthSecret missing → no gate (return null)
29
+ *
30
+ * Both project-level keys fail-CLOSED: if either read throws, this throws
31
+ * `AuthResolutionError` so middleware returns 503 instead of serving gated
32
+ * content without a check. A partial Upstash outage taking down one key
33
+ * while the other is reachable would otherwise create a fail-open window.
34
+ *
35
+ * We intentionally do NOT read `domainAuthSecret` here even though it is
36
+ * mirrored on every password write. The dashboard fan-out is best-effort,
37
+ * so a failed mirror write can leave the domain key pointing at a stale
38
+ * version. Reading the mirror would then accept old session cookies on
39
+ * that host. Always reading the project-level key keeps a single source
40
+ * of truth and costs one fewer Redis GET per request on custom domains.
41
+ *
42
+ * When `redis` is null (local dev without Upstash configured), returns
43
+ * null immediately so the CLI dev server doesn't try to gate anything.
44
+ */
45
+ export class AuthResolutionError extends Error {
46
+ constructor(message: string, public readonly cause?: unknown) {
47
+ super(message);
48
+ this.name = 'AuthResolutionError';
49
+ }
50
+ }
51
+
52
+ export async function resolveAuth(
53
+ projectSlug: string,
54
+ ): Promise<ResolvedAuth | null> {
55
+ if (!redis) return null;
56
+ const r = redis;
57
+
58
+ let projectSecret: AuthSecret | null;
59
+ let projectPublic: AuthPublic | null;
60
+
61
+ try {
62
+ const [projectSecretResult, projectPublicResult] = await Promise.all([
63
+ r.get(`projectAuthSecret:${projectSlug}`),
64
+ r.get(`projectAuthPublic:${projectSlug}`),
65
+ ]);
66
+ projectSecret = projectSecretResult as AuthSecret | null;
67
+ projectPublic = projectPublicResult as AuthPublic | null;
68
+ } catch (e) {
69
+ throw new AuthResolutionError(
70
+ 'Failed to read project auth keys from Redis',
71
+ e,
72
+ );
73
+ }
74
+
75
+ const secret = projectSecret;
76
+ const pub = projectPublic;
77
+
78
+ if (
79
+ !secret ||
80
+ typeof secret.hash !== 'string' ||
81
+ typeof secret.version !== 'number'
82
+ ) {
83
+ return null;
84
+ }
85
+
86
+ if (!pub || pub.enabled !== true) {
87
+ return null;
88
+ }
89
+
90
+ // setProjectAuthPublic filters publicPaths to strings on the write
91
+ // side, so we can trust the shape here and pass the array straight
92
+ // through. This keeps the array reference stable across requests so
93
+ // the matchPublicPath WeakMap cache actually amortizes the regex
94
+ // compile cost. (Defense-in-depth: still guard against non-array.)
95
+ const publicPaths: string[] = Array.isArray(pub.publicPaths)
96
+ ? (pub.publicPaths as string[])
97
+ : [];
98
+
99
+ return {
100
+ hash: secret.hash,
101
+ version: secret.version,
102
+ publicPaths,
103
+ };
104
+ }
105
+
106
+ // matchPublicPath is implemented in glob-match.ts and re-exported above
107
+ // for backwards compatibility. The split keeps the matcher (a pure
108
+ // function with no Redis dependency) importable from build-time modules
109
+ // like public-paths-resolver without dragging in the Redis client.
@@ -10,6 +10,8 @@
10
10
  * Then use getDocsConfig, getAllDocPaths, getMdxContent as needed.
11
11
  */
12
12
 
13
+ import fs from 'fs';
14
+ import path from 'path';
13
15
  import {
14
16
  fetchDocsConfig,
15
17
  fetchMdxContent,
@@ -31,6 +33,45 @@ function requireIsrMode(): void {
31
33
  }
32
34
  }
33
35
 
36
+ /**
37
+ * Gated on JAMDESK_PROJECTS_DIR (only set by local dev flows) so prod deploys
38
+ * can never leak filesystem content. NODE_ENV is unreliable here — Next.js's
39
+ * server-render worker reports NODE_ENV=production inside `next dev`.
40
+ */
41
+ function devFallbackDir(projectSlug: string): string | null {
42
+ const projectsDir = process.env.JAMDESK_PROJECTS_DIR;
43
+ if (!projectsDir) return null;
44
+ const dir = path.join(projectsDir, projectSlug);
45
+ try {
46
+ return fs.existsSync(dir) ? dir : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function walkMdx(dir: string): string[] {
53
+ const out: string[] = [];
54
+ const walk = (current: string, prefix: string) => {
55
+ let entries: fs.Dirent[];
56
+ try {
57
+ entries = fs.readdirSync(current, { withFileTypes: true });
58
+ } catch {
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (entry.name.startsWith('.')) continue;
63
+ if (entry.isDirectory()) {
64
+ if (entry.name === 'images' || entry.name === 'node_modules') continue;
65
+ walk(path.join(current, entry.name), path.join(prefix, entry.name));
66
+ } else if (entry.name.endsWith('.mdx')) {
67
+ out.push(path.join(prefix, entry.name.replace(/\.mdx$/, '')));
68
+ }
69
+ }
70
+ };
71
+ walk(dir, '');
72
+ return out;
73
+ }
74
+
34
75
  /**
35
76
  * Get docs.json configuration for a project.
36
77
  *
@@ -44,10 +85,18 @@ function requireIsrMode(): void {
44
85
  export async function getDocsConfig(projectSlug: string): Promise<DocsConfig> {
45
86
  requireIsrMode();
46
87
  const config = await fetchDocsConfig(projectSlug);
47
- if (!config) {
48
- throw new Error(`Project not found in R2: ${projectSlug}`);
88
+ if (config) return config;
89
+
90
+ const localDir = devFallbackDir(projectSlug);
91
+ if (localDir) {
92
+ try {
93
+ const raw = fs.readFileSync(path.join(localDir, 'docs.json'), 'utf8');
94
+ return JSON.parse(raw) as DocsConfig;
95
+ } catch {
96
+ // fall through to throw below
97
+ }
49
98
  }
50
- return config;
99
+ throw new Error(`Project not found in R2: ${projectSlug}`);
51
100
  }
52
101
 
53
102
  /**
@@ -60,6 +109,8 @@ export async function getDocsConfig(projectSlug: string): Promise<DocsConfig> {
60
109
  */
61
110
  export async function getAllDocPaths(projectSlug: string): Promise<string[]> {
62
111
  requireIsrMode();
112
+ const localDir = devFallbackDir(projectSlug);
113
+ if (localDir) return walkMdx(localDir);
63
114
  return listAllPaths(projectSlug);
64
115
  }
65
116
 
@@ -75,7 +126,19 @@ export async function getMdxContent(
75
126
  pagePath: string
76
127
  ): Promise<string> {
77
128
  requireIsrMode();
78
- return fetchMdxContent(projectSlug, pagePath);
129
+ try {
130
+ return await fetchMdxContent(projectSlug, pagePath);
131
+ } catch (err) {
132
+ const localDir = devFallbackDir(projectSlug);
133
+ if (localDir) {
134
+ try {
135
+ return fs.readFileSync(path.join(localDir, pagePath + '.mdx'), 'utf8');
136
+ } catch {
137
+ // fall through to re-throw below
138
+ }
139
+ }
140
+ throw err;
141
+ }
79
142
  }
80
143
 
81
144
  /**
@@ -90,7 +153,19 @@ export async function getSnippet(
90
153
  snippetPath: string
91
154
  ): Promise<string> {
92
155
  requireIsrMode();
93
- return fetchSnippet(projectSlug, snippetPath);
156
+ try {
157
+ return await fetchSnippet(projectSlug, snippetPath);
158
+ } catch (err) {
159
+ const localDir = devFallbackDir(projectSlug);
160
+ if (localDir) {
161
+ try {
162
+ return fs.readFileSync(path.join(localDir, 'snippets', snippetPath), 'utf8');
163
+ } catch {
164
+ // fall through to re-throw
165
+ }
166
+ }
167
+ throw err;
168
+ }
94
169
  }
95
170
 
96
171
  /**
@@ -104,7 +179,8 @@ export async function getSnippet(
104
179
  export async function projectExists(projectSlug: string): Promise<boolean> {
105
180
  requireIsrMode();
106
181
  const config = await fetchDocsConfig(projectSlug);
107
- return config !== null;
182
+ if (config !== null) return true;
183
+ return devFallbackDir(projectSlug) !== null;
108
184
  }
109
185
 
110
186
  /**