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.
- package/dist/__tests__/integration/validate.integration.test.js +14 -0
- package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
- package/dist/__tests__/unit/validate-password-warning.test.d.ts +2 -0
- package/dist/__tests__/unit/validate-password-warning.test.d.ts.map +1 -0
- package/dist/__tests__/unit/validate-password-warning.test.js +25 -0
- package/dist/__tests__/unit/validate-password-warning.test.js.map +1 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +13 -0
- package/dist/commands/validate.js.map +1 -1
- package/package.json +2 -2
- package/vendored/app/(unlock)/jd/unlock/UnlockForm.tsx +176 -0
- package/vendored/app/(unlock)/jd/unlock/page.tsx +157 -0
- package/vendored/app/api/jd/unlock/route.ts +325 -0
- package/vendored/app/globals.css +14 -14
- package/vendored/app/layout.tsx +29 -4
- package/vendored/lib/auth-resolver.ts +109 -0
- package/vendored/lib/docs-isr.ts +82 -6
- package/vendored/lib/docs-types.ts +26 -0
- package/vendored/lib/extract-highlights.ts +5 -0
- package/vendored/lib/glob-match.ts +83 -0
- package/vendored/lib/middleware-helpers.ts +26 -0
- package/vendored/lib/public-paths-resolver.ts +227 -0
- package/vendored/lib/redis.ts +94 -0
- package/vendored/lib/sanitize-from.ts +36 -0
- package/vendored/postcss.config.mjs +6 -0
- package/vendored/schema/docs-schema.json +50 -0
- package/vendored/shared/auth-cookie.ts +163 -0
|
@@ -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
|
+
}
|
package/vendored/app/globals.css
CHANGED
|
@@ -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";
|
package/vendored/app/layout.tsx
CHANGED
|
@@ -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.
|
package/vendored/lib/docs-isr.ts
CHANGED
|
@@ -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 (
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
+
if (config !== null) return true;
|
|
183
|
+
return devFallbackDir(projectSlug) !== null;
|
|
108
184
|
}
|
|
109
185
|
|
|
110
186
|
/**
|