jamdesk 1.1.222 → 1.1.223
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 +1 -1
- package/vendored/app/api/jd/auth/callback/route.ts +7 -2
- package/vendored/app/api/jd/auth/logout/route.ts +3 -1
- package/vendored/app/api/jd/auth/me/route.ts +2 -1
- package/vendored/app/api/jd/unlock/route.ts +21 -19
- package/vendored/lib/auth-gate-rules.ts +43 -0
- package/vendored/lib/auth-resolver.ts +66 -56
- package/vendored/lib/jwt-key-build-warning.ts +8 -7
- package/vendored/lib/r2-public-storage-guard.ts +26 -50
- package/vendored/lib/screenshot-capture.ts +6 -3
- package/vendored/lib/snippet-loader-isr.ts +184 -37
- package/vendored/lib/viewer-navigation.ts +3 -1
- package/vendored/scripts/compile-snippets.cjs +60 -4
- package/vendored/workspace-package-lock.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.223",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* a cross-site POST would store an attacker-chosen session, groups and
|
|
14
14
|
* playground inputs included, in the victim's cookie jar).
|
|
15
15
|
* 404 unknown-project / not-enabled — no slug header, or project isn't in
|
|
16
|
-
* jwt mode (
|
|
16
|
+
* jwt mode (resolveAuth null, or a password-mode auth).
|
|
17
|
+
* 401 no-signing-key — jwt mode is on but no public key is stored
|
|
18
|
+
* (resolveAuth `locked`): the site stays gated and no token can verify.
|
|
17
19
|
* 400 bad-request — malformed JSON or missing/oversized jwt field.
|
|
18
20
|
* 401 <reason> — verifyDocsJwt rejected the token (invalid signature,
|
|
19
21
|
* expired, host mismatch, etc.) — reason echoed from jwt-session.
|
|
@@ -60,9 +62,12 @@ export async function POST(req: Request): Promise<NextResponse> {
|
|
|
60
62
|
if (e instanceof AuthResolutionError) return NextResponse.json({ ok: false, error: 'unavailable' }, { status: 503 });
|
|
61
63
|
throw e;
|
|
62
64
|
}
|
|
63
|
-
if (!auth || auth.authType !== 'jwt'
|
|
65
|
+
if (!auth || auth.authType !== 'jwt') {
|
|
64
66
|
return NextResponse.json({ ok: false, error: 'not-enabled' }, { status: 404 });
|
|
65
67
|
}
|
|
68
|
+
if (auth.locked || !auth.publicKeyPem) {
|
|
69
|
+
return NextResponse.json({ ok: false, error: 'no-signing-key' }, { status: 401 });
|
|
70
|
+
}
|
|
66
71
|
|
|
67
72
|
const host = getRequestHost(req);
|
|
68
73
|
const outcome = await verifyDocsJwt(body.jwt, auth.publicKeyPem, host);
|
|
@@ -54,7 +54,9 @@ export async function GET(req: Request): Promise<Response> {
|
|
|
54
54
|
if (slug) {
|
|
55
55
|
try {
|
|
56
56
|
const auth = await resolveAuth(slug);
|
|
57
|
-
|
|
57
|
+
// A locked site has no working login (no signing key): send visitors
|
|
58
|
+
// to '/', where the gate shows the explainer, like it does for pages.
|
|
59
|
+
if (auth?.authType === 'jwt' && !auth.locked && auth.loginUrl) location = auth.loginUrl;
|
|
58
60
|
} catch { /* logout must always succeed; fall back to '/' */ }
|
|
59
61
|
}
|
|
60
62
|
const headers = new Headers({ Location: location, 'Cache-Control': 'no-store' });
|
|
@@ -33,7 +33,8 @@ export async function GET(req: Request): Promise<NextResponse> {
|
|
|
33
33
|
|
|
34
34
|
let auth;
|
|
35
35
|
try { auth = await resolveAuth(slug); } catch { return NextResponse.json(UNAUTHENTICATED, noStore); }
|
|
36
|
-
|
|
36
|
+
// A locked jwt site (no signing key) has no session anyone can hold.
|
|
37
|
+
if (!auth || auth.authType !== 'jwt' || auth.locked) return NextResponse.json(UNAUTHENTICATED, noStore);
|
|
37
38
|
|
|
38
39
|
const cookieHeader = req.headers.get('cookie') || '';
|
|
39
40
|
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)jd_auth_${slug}=([^;]+)`));
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* Flow:
|
|
8
8
|
* 1. Validate inputs (slug, password fields)
|
|
9
9
|
* 2. Check rate limits (per-IP + per-slug backstop) — BEFORE scrypt
|
|
10
|
-
* 3.
|
|
11
|
-
*
|
|
10
|
+
* 3. Resolve the gate via resolveAuth (the edge gate's own function), so a
|
|
11
|
+
* cookie is minted only for a site the gate treats as an ACTIVE
|
|
12
|
+
* password gate: not for an off/missing record, a jwt site, or a LOCKED
|
|
13
|
+
* site with no usable hash
|
|
14
|
+
* 4. Always run scrypt (DUMMY_HASH when there is no hash) to prevent timing oracles
|
|
12
15
|
* 5. On match: sign host-bound cookie, 303 to sanitized `from`
|
|
13
16
|
* 6. On mismatch: 303 to unlock page with error=1
|
|
14
17
|
*/
|
|
@@ -19,7 +22,7 @@ export const dynamic = 'force-dynamic';
|
|
|
19
22
|
import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
|
|
20
23
|
import { promisify } from 'util';
|
|
21
24
|
import { redis } from '@/lib/redis';
|
|
22
|
-
import {
|
|
25
|
+
import { resolveAuth, type ResolvedAuth } from '@/lib/auth-resolver';
|
|
23
26
|
import { sanitizeFrom } from '@/lib/sanitize-from';
|
|
24
27
|
import { VALID_SLUG_RE } from '@/lib/middleware-helpers';
|
|
25
28
|
import { signAuthCookie } from '@/shared/auth-cookie';
|
|
@@ -220,13 +223,6 @@ interface StoredSecret {
|
|
|
220
223
|
version: number;
|
|
221
224
|
}
|
|
222
225
|
|
|
223
|
-
// Returns null only when Redis is unconfigured (local dev). Real errors
|
|
224
|
-
// propagate so POST can fail closed with 503 — matching resolveAuth.
|
|
225
|
-
async function fetchStoredSecret(slug: string): Promise<StoredSecret | null> {
|
|
226
|
-
if (!redis) return null;
|
|
227
|
-
return getProjectAuthSecret(slug);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
226
|
export async function POST(req: Request): Promise<Response> {
|
|
231
227
|
// Login-CSRF guard: a cross-site form POST would store a session in the
|
|
232
228
|
// victim's jar. Checked first — before scrypt, before rate limiting — so a
|
|
@@ -297,15 +293,9 @@ export async function POST(req: Request): Promise<Response> {
|
|
|
297
293
|
});
|
|
298
294
|
}
|
|
299
295
|
|
|
300
|
-
let
|
|
301
|
-
let authType: 'password' | 'jwt' | undefined;
|
|
296
|
+
let auth: ResolvedAuth | null;
|
|
302
297
|
try {
|
|
303
|
-
|
|
304
|
-
fetchStoredSecret(slug),
|
|
305
|
-
getProjectAuthPublic(slug),
|
|
306
|
-
]);
|
|
307
|
-
stored = storedResult;
|
|
308
|
-
authType = pubResult?.authType;
|
|
298
|
+
auth = await resolveAuth(slug);
|
|
309
299
|
} catch (err) {
|
|
310
300
|
console.error('[unlock] Redis fetch failed — failing closed', {
|
|
311
301
|
slug,
|
|
@@ -322,10 +312,22 @@ export async function POST(req: Request): Promise<Response> {
|
|
|
322
312
|
// unlock form verify and mint a v1 cookie the jwt gate rejects — not a
|
|
323
313
|
// bypass (the jwt gate never accepts a v1 cookie), but a confusing dead
|
|
324
314
|
// end for the user. Fail fast instead.
|
|
325
|
-
if (authType === 'jwt') {
|
|
315
|
+
if (auth?.authType === 'jwt') {
|
|
326
316
|
return new Response('not-enabled', { status: 404 });
|
|
327
317
|
}
|
|
328
318
|
|
|
319
|
+
// Only an ACTIVE password gate has a hash to verify against. null (local
|
|
320
|
+
// dev, protection off, never built) and LOCKED (enabled, no usable hash —
|
|
321
|
+
// the shape rule is isUsableAuthSecret, shared with the edge) both fall
|
|
322
|
+
// through to DUMMY_HASH with `stored` null, which can never mint: the
|
|
323
|
+
// DUMMY_HASH plaintext is public (this route ships in the npm CLI), so the
|
|
324
|
+
// `!stored` checks below are what make it safe, and the master password
|
|
325
|
+
// needs `stored` too.
|
|
326
|
+
const stored: StoredSecret | null =
|
|
327
|
+
auth && !auth.locked && typeof auth.hash === 'string'
|
|
328
|
+
? { hash: auth.hash, version: auth.version }
|
|
329
|
+
: null;
|
|
330
|
+
|
|
329
331
|
const hashToVerify = stored?.hash ?? DUMMY_HASH;
|
|
330
332
|
|
|
331
333
|
// Always run scrypt — even with DUMMY_HASH when no password is set — to
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auth plane's shape rules, in one Redis-free place.
|
|
3
|
+
*
|
|
4
|
+
* Every component that decides "is this site gated?" or "can this stored
|
|
5
|
+
* secret unlock it?" must give the same answer, or one of them opens a site
|
|
6
|
+
* the others treat as closed. Consumers: resolveAuth and isProjectGated
|
|
7
|
+
* (auth-resolver.ts), the build's R2 write guard (r2-public-storage-guard.ts),
|
|
8
|
+
* and through resolveAuth the unlock route.
|
|
9
|
+
*
|
|
10
|
+
* scripts/lib/public-storage-guard.cjs (the jamdesk.cjs upload path) cannot
|
|
11
|
+
* import a TS module, so it carries a copy of isGatedPublicRecord; the drift
|
|
12
|
+
* test in __tests__/scripts/lib/public-storage-guard.test.ts pins the two to
|
|
13
|
+
* the same truth table.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `projectAuthPublic.enabled === true` is gated, whether or not the mode's
|
|
18
|
+
* secret exists. A missing secret makes the gate LOCKED, never open. Strict
|
|
19
|
+
* `=== true`: the build writes a boolean, and 'true' or 1 are not gates.
|
|
20
|
+
*/
|
|
21
|
+
export function isGatedPublicRecord(pub: unknown): boolean {
|
|
22
|
+
return (pub as { enabled?: unknown } | null | undefined)?.enabled === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A password secret that can verify a password: string hash, numeric version. */
|
|
26
|
+
export function isUsableAuthSecret(
|
|
27
|
+
value: unknown,
|
|
28
|
+
): value is { hash: string; version: number } {
|
|
29
|
+
const v = value as { hash?: unknown; version?: unknown } | null | undefined;
|
|
30
|
+
return typeof v?.hash === 'string' && typeof v.version === 'number';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A jwt secret that can verify a token: non-empty PEM, numeric version. */
|
|
34
|
+
export function isUsableJwtSecret(
|
|
35
|
+
value: unknown,
|
|
36
|
+
): value is { publicKeyPem: string; version: number } {
|
|
37
|
+
const v = value as { publicKeyPem?: unknown; version?: unknown } | null | undefined;
|
|
38
|
+
return (
|
|
39
|
+
typeof v?.publicKeyPem === 'string' &&
|
|
40
|
+
v.publicKeyPem.length > 0 &&
|
|
41
|
+
typeof v.version === 'number'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
import { redis } from './redis';
|
|
2
2
|
import { getProjectAuthSecret, getProjectAuthPublic, getProjectJwtSecret } from './cached-redis';
|
|
3
3
|
import { matchPublicPath, resolveRequiredGroups } from './glob-match';
|
|
4
|
+
import { isGatedPublicRecord, isUsableAuthSecret, isUsableJwtSecret } from './auth-gate-rules';
|
|
4
5
|
|
|
5
6
|
export { matchPublicPath, resolveRequiredGroups };
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
interface ResolvedAuthBase {
|
|
8
9
|
authType: 'password' | 'jwt';
|
|
9
|
-
hash?: string;
|
|
10
|
-
publicKeyPem?: string;
|
|
11
|
-
version: number;
|
|
12
10
|
publicPaths: string[];
|
|
13
11
|
loginUrl?: string;
|
|
14
12
|
groupsPaths?: Record<string, string[]>;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
/** Gate with a usable secret: a cookie bound to `version` can pass. */
|
|
16
|
+
export interface ActiveAuth extends ResolvedAuthBase {
|
|
17
|
+
locked?: undefined;
|
|
18
|
+
hash?: string;
|
|
19
|
+
publicKeyPem?: string;
|
|
19
20
|
version: number;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Gate with NO usable secret (protection on, password not set / signing key
|
|
25
|
+
* cleared / malformed secret). Nothing can unlock it. It deliberately has no
|
|
26
|
+
* `version`, `hash` or `publicKeyPem`, so a consumer that verifies a cookie or
|
|
27
|
+
* token must narrow on `locked` first or it won't compile.
|
|
28
|
+
*/
|
|
29
|
+
export interface LockedAuth extends ResolvedAuthBase {
|
|
30
|
+
locked: true;
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
export type ResolvedAuth = ActiveAuth | LockedAuth;
|
|
34
|
+
|
|
27
35
|
interface AuthPublic {
|
|
28
36
|
enabled?: boolean;
|
|
29
37
|
publicPaths?: string[];
|
|
@@ -35,13 +43,29 @@ interface AuthPublic {
|
|
|
35
43
|
/**
|
|
36
44
|
* Resolve active auth protection (password OR jwt) for a project.
|
|
37
45
|
*
|
|
38
|
-
* Truth table — the config plane's declared
|
|
39
|
-
*
|
|
46
|
+
* Truth table — `enabled === true` ALWAYS gates; the config plane's declared
|
|
47
|
+
* `authType` alone picks the mode, and only that mode's secret can unlock it:
|
|
40
48
|
* projectAuthPublic missing OR enabled!==true → no gate (return null)
|
|
41
49
|
* authType==='jwt' AND projectJwtSecret has a publicKeyPem → jwt gate
|
|
42
|
-
* authType==='jwt' AND projectJwtSecret missing/malformed →
|
|
50
|
+
* authType==='jwt' AND projectJwtSecret missing/malformed → LOCKED jwt gate
|
|
43
51
|
* authType!=='jwt' (password, incl. absent) AND projectAuthSecret has a hash → password gate
|
|
44
|
-
* authType!=='jwt' AND projectAuthSecret missing/malformed →
|
|
52
|
+
* authType!=='jwt' AND projectAuthSecret missing/malformed → LOCKED password gate
|
|
53
|
+
*
|
|
54
|
+
* LOCKED (`locked: true`) is the state between the build turning protection
|
|
55
|
+
* on and the owner setting a password or generating a key, or after "clear
|
|
56
|
+
* password" / "Clear signing key". The docs promise every request is rejected
|
|
57
|
+
* there, so it must never fall back to "no gate": a locked gate still honors
|
|
58
|
+
* publicPaths and answers like any unauthed request for its mode (unlock page,
|
|
59
|
+
* loginUrl redirect, plain 401), but no cookie, token or password passes it.
|
|
60
|
+
* This used to return null, which served a just-enabled site to the public.
|
|
61
|
+
*
|
|
62
|
+
* Project API keys (search, MCP) still work on a locked site, by design: they
|
|
63
|
+
* are a separate grant the owner created, checked before this gate's cookie
|
|
64
|
+
* logic. The owner revokes the key to stop that access.
|
|
65
|
+
*
|
|
66
|
+
* The shape rules (what counts as gated, what counts as a usable secret) live
|
|
67
|
+
* in auth-gate-rules.ts so the R2 write guard and the unlock route cannot
|
|
68
|
+
* drift from this function.
|
|
45
69
|
*
|
|
46
70
|
* Coexistence is by design: BOTH secret keys may exist simultaneously
|
|
47
71
|
* during a password→JWT migration (the build writes the new key before
|
|
@@ -102,7 +126,7 @@ export async function resolveAuth(
|
|
|
102
126
|
() => getProjectAuthPublic(projectSlug),
|
|
103
127
|
);
|
|
104
128
|
|
|
105
|
-
if (!pub || pub
|
|
129
|
+
if (!pub || !isGatedPublicRecord(pub)) {
|
|
106
130
|
return null;
|
|
107
131
|
}
|
|
108
132
|
|
|
@@ -117,42 +141,37 @@ export async function resolveAuth(
|
|
|
117
141
|
const authType: 'password' | 'jwt' = pub.authType === 'jwt' ? 'jwt' : 'password';
|
|
118
142
|
|
|
119
143
|
if (authType === 'jwt') {
|
|
120
|
-
const
|
|
144
|
+
const loginUrl = typeof pub.loginUrl === 'string' ? pub.loginUrl : undefined;
|
|
145
|
+
// Kept on a locked gate too: group pages must not be opened by an
|
|
146
|
+
// overlapping public glob just because the key is missing.
|
|
147
|
+
const groupsPaths =
|
|
148
|
+
pub.groupsPaths && typeof pub.groupsPaths === 'object' ? pub.groupsPaths : undefined;
|
|
149
|
+
const jwtSecret = await readOrFail<unknown>(
|
|
121
150
|
() => getProjectJwtSecret(projectSlug),
|
|
122
151
|
);
|
|
123
|
-
if (
|
|
124
|
-
!jwtSecret ||
|
|
125
|
-
typeof jwtSecret.publicKeyPem !== 'string' ||
|
|
126
|
-
jwtSecret.publicKeyPem.length === 0 ||
|
|
127
|
-
typeof jwtSecret.version !== 'number'
|
|
128
|
-
) {
|
|
152
|
+
if (!isUsableJwtSecret(jwtSecret)) {
|
|
129
153
|
// authType says jwt but the jwt-own secret isn't there (or is
|
|
130
|
-
// malformed)
|
|
131
|
-
// mode must NEVER satisfy jwt mode
|
|
132
|
-
return
|
|
154
|
+
// malformed): locked, never open. A lingering password hash from a
|
|
155
|
+
// prior mode must NEVER satisfy jwt mode either.
|
|
156
|
+
return { authType, locked: true, publicPaths, loginUrl, groupsPaths };
|
|
133
157
|
}
|
|
134
158
|
return {
|
|
135
159
|
authType,
|
|
136
160
|
publicKeyPem: jwtSecret.publicKeyPem,
|
|
137
161
|
version: jwtSecret.version,
|
|
138
162
|
publicPaths,
|
|
139
|
-
loginUrl
|
|
140
|
-
groupsPaths
|
|
141
|
-
pub.groupsPaths && typeof pub.groupsPaths === 'object'
|
|
142
|
-
? pub.groupsPaths
|
|
143
|
-
: undefined,
|
|
163
|
+
loginUrl,
|
|
164
|
+
groupsPaths,
|
|
144
165
|
};
|
|
145
166
|
}
|
|
146
167
|
|
|
147
|
-
const secret = await readOrFail<
|
|
168
|
+
const secret = await readOrFail<unknown>(
|
|
148
169
|
() => getProjectAuthSecret(projectSlug),
|
|
149
170
|
);
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
) {
|
|
155
|
-
return null;
|
|
171
|
+
if (!isUsableAuthSecret(secret)) {
|
|
172
|
+
// No password set yet (or "clear password", or a malformed record):
|
|
173
|
+
// locked. A jwt key that may still exist never satisfies password mode.
|
|
174
|
+
return { authType, locked: true, publicPaths };
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
return {
|
|
@@ -171,7 +190,7 @@ export async function resolveAuth(
|
|
|
171
190
|
// like public-paths-resolver without dragging in the Redis client.
|
|
172
191
|
|
|
173
192
|
/**
|
|
174
|
-
* Quick boolean check: is this project
|
|
193
|
+
* Quick boolean check: is this project gated (password or jwt) right now?
|
|
175
194
|
* Used by R2 read paths to CHOOSE WHICH FETCH MECHANISM to use:
|
|
176
195
|
* - true → S3 SDK (HMAC-authed, slower)
|
|
177
196
|
* - false → CF public domain (fast, cached)
|
|
@@ -182,31 +201,22 @@ export async function resolveAuth(
|
|
|
182
201
|
* fetch mechanism. Therefore: fail OPEN (return false) on Redis error so
|
|
183
202
|
* brownouts don't nullify the CDN-cache latency win.
|
|
184
203
|
*
|
|
185
|
-
* Reads go through cached-redis (
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* path below still retries on the next call rather than pinning `false`.
|
|
204
|
+
* Reads go through cached-redis (getProjectAuthPublic), the same wrapper
|
|
205
|
+
* resolveAuth uses, so the two share one per-instance cache entry.
|
|
206
|
+
* cached-redis does NOT cache fetch errors, so the fail-open path below
|
|
207
|
+
* still retries on the next call rather than pinning `false`.
|
|
190
208
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
209
|
+
* Same definition as resolveAuth: `enabled === true` is gated, whether or not
|
|
210
|
+
* the mode's secret exists (a LOCKED gate is still a gate), so only the public
|
|
211
|
+
* record is read. r2-public-storage-guard uses the same rule on the write
|
|
212
|
+
* side: a gated site never gets versioned public keys, so the read side must
|
|
213
|
+
* pick the S3 path for exactly those sites or it would find nothing.
|
|
195
214
|
*/
|
|
196
215
|
export async function isProjectGated(slug: string): Promise<boolean> {
|
|
197
216
|
if (!redis) return false;
|
|
198
217
|
|
|
199
218
|
try {
|
|
200
|
-
|
|
201
|
-
// disabled tenant is one GET, and only the mode's own secret is read.
|
|
202
|
-
const pub = await getProjectAuthPublic(slug);
|
|
203
|
-
if (pub?.enabled !== true) return false;
|
|
204
|
-
if (pub.authType === 'jwt') {
|
|
205
|
-
const jwtSecret = await getProjectJwtSecret(slug);
|
|
206
|
-
return typeof jwtSecret?.publicKeyPem === 'string';
|
|
207
|
-
}
|
|
208
|
-
const secret = await getProjectAuthSecret(slug);
|
|
209
|
-
return typeof secret?.hash === 'string';
|
|
219
|
+
return isGatedPublicRecord(await getProjectAuthPublic(slug));
|
|
210
220
|
} catch {
|
|
211
221
|
// Fail OPEN: this is a read-mechanism choice, not access control.
|
|
212
222
|
// Gating already happened upstream in proxy.ts. Failing closed here
|
|
@@ -4,12 +4,13 @@ import type { BuildWarning } from '../shared/status-reporter.js'; // vendored co
|
|
|
4
4
|
* Warn when a project declares JWT authentication but has no signing key.
|
|
5
5
|
*
|
|
6
6
|
* The truth table in auth-resolver.ts is explicit: `authType === 'jwt'` with a
|
|
7
|
-
* missing or malformed `projectJwtSecret`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* missing or malformed `projectJwtSecret` is a LOCKED gate. Every visitor is
|
|
8
|
+
* turned away, because no token can be verified. That is the correct fail-safe
|
|
9
|
+
* for a key that was cleared deliberately, but it is also reachable by
|
|
10
|
+
* accident: commit `auth.jwt.enabled: true`, publish, and never click Generate
|
|
11
|
+
* signing key. The build then succeeds, nobody can read the site, and nothing
|
|
12
|
+
* says why. The dashboard card does report it, but a customer who publishes
|
|
13
|
+
* from git has no reason to open that page.
|
|
13
14
|
*
|
|
14
15
|
* Silent for `enabled: false`, where a public site is the stated intent, and
|
|
15
16
|
* silent for password tenants, whose own secret has its own row in that table.
|
|
@@ -32,7 +33,7 @@ export function buildJwtKeyWarning(input: {
|
|
|
32
33
|
// docs.json is where the opt-in lives and what an author edits to change it.
|
|
33
34
|
file: 'docs.json',
|
|
34
35
|
message:
|
|
35
|
-
'auth.jwt.enabled is true but this project has no signing key, so
|
|
36
|
+
'auth.jwt.enabled is true but this project has no signing key, so the site is locked: every visitor is turned away until a signing key is generated. ' +
|
|
36
37
|
'Generate an Ed25519 signing key under Settings, or set auth.jwt.enabled to false if the site is meant to be public.',
|
|
37
38
|
};
|
|
38
39
|
}
|
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
import { redis } from './redis.js';
|
|
2
2
|
import { logger } from '../shared/logger.js';
|
|
3
|
-
|
|
4
|
-
interface AuthSecret {
|
|
5
|
-
hash?: unknown;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface JwtSecret {
|
|
9
|
-
publicKeyPem?: unknown;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface AuthPublic {
|
|
13
|
-
enabled?: unknown;
|
|
14
|
-
}
|
|
3
|
+
import { isGatedPublicRecord } from './auth-gate-rules.js';
|
|
15
4
|
|
|
16
5
|
function publicDomainConfigured(): boolean {
|
|
17
6
|
return !!process.env.R2_PUBLIC_DOMAIN?.trim();
|
|
@@ -21,31 +10,12 @@ function overrideEnabled(): boolean {
|
|
|
21
10
|
return process.env.R2_ALLOW_GATED_PUBLIC_BUCKET?.trim() === 'true';
|
|
22
11
|
}
|
|
23
12
|
|
|
24
|
-
// Considers EITHER secret key — a JWT-gated project must also be treated
|
|
25
|
-
// as gated here, or its content gets written to public/versioned R2 keys
|
|
26
|
-
// (defeating the guard's purpose). Deliberately a SUPERSET of
|
|
27
|
-
// isProjectGated's mode-bound truth table (which consults only the key for
|
|
28
|
-
// the configured authType): a lingering hash on a jwt tenant with no jwt
|
|
29
|
-
// key makes this guard write gated keys while the reader takes the public
|
|
30
|
-
// path and falls back to the S3 SDK — a latency cost, never an exposure.
|
|
31
|
-
// Fail-closed is the right bias for a write-side guard.
|
|
32
|
-
function isGated(
|
|
33
|
-
secret: AuthSecret | null,
|
|
34
|
-
pub: AuthPublic | null,
|
|
35
|
-
jwtSecret: JwtSecret | null,
|
|
36
|
-
): boolean {
|
|
37
|
-
return (
|
|
38
|
-
pub?.enabled === true &&
|
|
39
|
-
(typeof secret?.hash === 'string' || typeof jwtSecret?.publicKeyPem === 'string')
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
13
|
/**
|
|
44
14
|
* R2 write scheme for a build:
|
|
45
15
|
* - 'versioned+legacy': dual-write as normal (the versioned/public keys the
|
|
46
16
|
* CF custom domain serves are safe to write for this project).
|
|
47
|
-
* - 'legacy-only':
|
|
48
|
-
* `{slug}/<file>` keys. The versioned public keys are never written, so
|
|
17
|
+
* - 'legacy-only': gated project (password or jwt, locked or not) — write
|
|
18
|
+
* ONLY the legacy `{slug}/<file>` keys. The versioned public keys are never written, so
|
|
49
19
|
* gated content cannot enter the CF-served key space. The ISR reader keeps
|
|
50
20
|
* serving these projects from the S3 SDK path (isProjectGated=true), so the
|
|
51
21
|
* legacy keys are all it needs.
|
|
@@ -60,22 +30,36 @@ export type R2WriteScheme = 'versioned+legacy' | 'legacy-only';
|
|
|
60
30
|
* mechanism after request auth has already happened. The write side is
|
|
61
31
|
* different: before writing objects into a bucket that is exposed by
|
|
62
32
|
* R2_PUBLIC_DOMAIN, it must fail closed unless we can prove the project is not
|
|
63
|
-
*
|
|
33
|
+
* gated or an explicit customer-acceptance override is set.
|
|
64
34
|
*
|
|
65
|
-
* A
|
|
35
|
+
* A gated project resolves to `'legacy-only'` — its versioned/public
|
|
66
36
|
* keys are never written, so gated content stays out of the CF-served key
|
|
67
37
|
* space (the build still succeeds; the S3 read path serves it). Non-gated
|
|
68
38
|
* projects, the no-public-domain case, and the acceptance override all resolve
|
|
69
|
-
* to `'versioned+legacy'`.
|
|
39
|
+
* to `'versioned+legacy'`.
|
|
40
|
+
*
|
|
41
|
+
* Gated means `isGatedPublicRecord` (auth-gate-rules.ts): enabled === true,
|
|
42
|
+
* secret or not, the same rule as resolveAuth and isProjectGated. A site with
|
|
43
|
+
* protection on and no password/key yet is a LOCKED gate at the edge; writing
|
|
44
|
+
* its content to the public versioned keys would make it readable through the
|
|
45
|
+
* CDN path while the edge rejects every request.
|
|
46
|
+
*
|
|
47
|
+
* `declaredGated` is the build's own docs.json verdict. It wins over Redis:
|
|
48
|
+
* the build writes projectAuthPublic earlier, but that write is non-fatal, so
|
|
49
|
+
* on a failed write Redis still holds the previous (public) record and would
|
|
50
|
+
* send a newly gated site's content to the public keys. A declared gate is
|
|
51
|
+
* decided without a Redis read.
|
|
52
|
+
*
|
|
53
|
+
* Throws when gated status cannot be determined (Redis
|
|
70
54
|
* unavailable / read fails after retries) — we must never write to a public
|
|
71
55
|
* bucket unverified, and a Redis outage during upload is a real failure worth
|
|
72
56
|
* surfacing (rare — the build has already done many Redis reads by this point).
|
|
73
57
|
*/
|
|
74
58
|
export async function resolveR2WriteScheme(
|
|
75
59
|
slug: string,
|
|
76
|
-
opts: { retryAttempts?: number; retryDelayMs?: number } = {},
|
|
60
|
+
opts: { retryAttempts?: number; retryDelayMs?: number; declaredGated?: boolean } = {},
|
|
77
61
|
): Promise<R2WriteScheme> {
|
|
78
|
-
const { retryAttempts = 3, retryDelayMs = 200 } = opts;
|
|
62
|
+
const { retryAttempts = 3, retryDelayMs = 200, declaredGated = false } = opts;
|
|
79
63
|
if (!publicDomainConfigured()) return 'versioned+legacy';
|
|
80
64
|
if (overrideEnabled()) {
|
|
81
65
|
// The override must never run silently: it disables the only write-side
|
|
@@ -87,6 +71,7 @@ export async function resolveR2WriteScheme(
|
|
|
87
71
|
);
|
|
88
72
|
return 'versioned+legacy';
|
|
89
73
|
}
|
|
74
|
+
if (declaredGated) return 'legacy-only';
|
|
90
75
|
|
|
91
76
|
if (!redis) {
|
|
92
77
|
throw new Error(
|
|
@@ -100,21 +85,12 @@ export async function resolveR2WriteScheme(
|
|
|
100
85
|
// any read error) even for non-gated projects. An outage that outlasts the
|
|
101
86
|
// retries still fails closed — correct, since we must never write to a
|
|
102
87
|
// public bucket unverified.
|
|
103
|
-
let
|
|
104
|
-
let pub: AuthPublic | null = null;
|
|
105
|
-
let jwtSecret: JwtSecret | null = null;
|
|
88
|
+
let pub: unknown = null;
|
|
106
89
|
let lastErr: unknown;
|
|
107
90
|
let read = false;
|
|
108
91
|
for (let attempt = 1; attempt <= retryAttempts; attempt++) {
|
|
109
92
|
try {
|
|
110
|
-
|
|
111
|
-
client.get(`projectAuthSecret:${slug}`),
|
|
112
|
-
client.get(`projectAuthPublic:${slug}`),
|
|
113
|
-
client.get(`projectJwtSecret:${slug}`),
|
|
114
|
-
]);
|
|
115
|
-
secret = secretResult as AuthSecret | null;
|
|
116
|
-
pub = publicResult as AuthPublic | null;
|
|
117
|
-
jwtSecret = jwtSecretResult as JwtSecret | null;
|
|
93
|
+
pub = await client.get(`projectAuthPublic:${slug}`);
|
|
118
94
|
read = true;
|
|
119
95
|
break;
|
|
120
96
|
} catch (err) {
|
|
@@ -131,5 +107,5 @@ export async function resolveR2WriteScheme(
|
|
|
131
107
|
);
|
|
132
108
|
}
|
|
133
109
|
|
|
134
|
-
return
|
|
110
|
+
return isGatedPublicRecord(pub) ? 'legacy-only' : 'versioned+legacy';
|
|
135
111
|
}
|
|
@@ -113,9 +113,9 @@ const CAPTURE_COOKIE_TTL_MS = 5 * 60_000;
|
|
|
113
113
|
* nothing new. JWT mode gets no groups (least privilege): a group-restricted
|
|
114
114
|
* homepage captures as the gate's 404.
|
|
115
115
|
*
|
|
116
|
-
* Returns null when the site is ungated, or when the cookie can't be
|
|
117
|
-
* the capture then proceeds unauthenticated, as it did before this
|
|
118
|
-
* Never rejects: the capture starts it before launching Chromium and may
|
|
116
|
+
* Returns null when the site is ungated or locked, or when the cookie can't be
|
|
117
|
+
* minted — the capture then proceeds unauthenticated, as it did before this
|
|
118
|
+
* existed. Never rejects: the capture starts it before launching Chromium and may
|
|
119
119
|
* abandon it on timeout.
|
|
120
120
|
*/
|
|
121
121
|
export async function mintCaptureAuthCookie(
|
|
@@ -130,6 +130,9 @@ export async function mintCaptureAuthCookie(
|
|
|
130
130
|
return null;
|
|
131
131
|
}
|
|
132
132
|
if (!auth) return null;
|
|
133
|
+
// Locked (protection on, no password/key yet): the edge accepts no cookie,
|
|
134
|
+
// so capture what every visitor sees, the unlock page or login redirect.
|
|
135
|
+
if (auth.locked) return null;
|
|
133
136
|
const secret = process.env.EDGE_AUTH_SECRET;
|
|
134
137
|
if (!secret) {
|
|
135
138
|
console.warn(`Screenshot: ${slug} is gated but EDGE_AUTH_SECRET is unset; capture will hit the password wall`);
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
guardPropertyKey,
|
|
26
26
|
KEY_GUARD_NAME,
|
|
27
27
|
STRICT_MODE_PROLOGUE,
|
|
28
|
+
MdxEvalGuardError,
|
|
28
29
|
} from './mdx-eval-guard';
|
|
29
30
|
import { extractUtilityCandidates, MAX_UTILITY_CANDIDATES } from './user-utility-css';
|
|
30
31
|
import { logger } from '../shared/logger';
|
|
@@ -250,6 +251,125 @@ function extractExports(transpiledCode: string): Record<string, string> {
|
|
|
250
251
|
return exports;
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Turn transpiled snippet code into ONE function body that declares everything
|
|
256
|
+
* at the file's top level and returns its named exports as an object.
|
|
257
|
+
*
|
|
258
|
+
* Why: per-export compile (extractExports) runs each export in its own
|
|
259
|
+
* function, so anything it references at the file's top level (a sibling
|
|
260
|
+
* `export const`, a non-exported helper) is "not defined" at render and the
|
|
261
|
+
* node silently drops. `jamdesk dev` imports the file as a real module, where
|
|
262
|
+
* that works, so the same snippet rendered locally and vanished in prod.
|
|
263
|
+
*
|
|
264
|
+
* The RETURNED names are exactly the ones extractExports finds (`export const`
|
|
265
|
+
* and plain `export function`). Other forms (`export let`, `export class`,
|
|
266
|
+
* `export { a as b }`, default exports) are still declared, so siblings can
|
|
267
|
+
* use them, but stay out of the result: a different export count would change
|
|
268
|
+
* which component loadSnippetsForIsr's single-export alias fallback picks for
|
|
269
|
+
* snippets that already work. `__proto__` is never returned, so no caller
|
|
270
|
+
* that copies exports by assignment can set a prototype through it.
|
|
271
|
+
*
|
|
272
|
+
* Runs on Babel OUTPUT, after mdxEvalGuardPlugin has already checked the
|
|
273
|
+
* author's code: it only removes `export` keywords and appends the return, so
|
|
274
|
+
* it adds no author-controlled code. Returns null for shapes it does not
|
|
275
|
+
* handle (re-exports from another module, destructured exports); the caller
|
|
276
|
+
* then uses the per-export path, exactly as before.
|
|
277
|
+
*/
|
|
278
|
+
export function toSnippetModuleBody(transpiledCode: string): string | null {
|
|
279
|
+
const names: string[] = [];
|
|
280
|
+
let unsupported = false;
|
|
281
|
+
|
|
282
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
283
|
+
const stripExports = ({ types: t }: { types: any }) => ({
|
|
284
|
+
visitor: {
|
|
285
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
286
|
+
ExportNamedDeclaration(path: any) {
|
|
287
|
+
const { node } = path;
|
|
288
|
+
if (node.source) {
|
|
289
|
+
unsupported = true;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const decl = node.declaration;
|
|
293
|
+
if (!decl) {
|
|
294
|
+
// `export { a as b }`: the locals are already declared.
|
|
295
|
+
path.remove();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (t.isVariableDeclaration(decl)) {
|
|
299
|
+
for (const d of decl.declarations) {
|
|
300
|
+
if (!t.isIdentifier(d.id)) {
|
|
301
|
+
unsupported = true;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (decl.kind === 'const') names.push(d.id.name);
|
|
305
|
+
}
|
|
306
|
+
} else if (t.isFunctionDeclaration(decl) && decl.id) {
|
|
307
|
+
if (!decl.async && !decl.generator) names.push(decl.id.name);
|
|
308
|
+
} else if (!(t.isClassDeclaration(decl) && decl.id)) {
|
|
309
|
+
unsupported = true;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
path.replaceWith(decl);
|
|
313
|
+
},
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
315
|
+
ExportDefaultDeclaration(path: any) {
|
|
316
|
+
const decl = path.node.declaration;
|
|
317
|
+
if ((t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id) {
|
|
318
|
+
path.replaceWith(decl);
|
|
319
|
+
} else {
|
|
320
|
+
path.remove();
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
ExportAllDeclaration() {
|
|
324
|
+
unsupported = true;
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
let code: string | null | undefined;
|
|
330
|
+
try {
|
|
331
|
+
code = transform(transpiledCode, {
|
|
332
|
+
plugins: [stripExports],
|
|
333
|
+
sourceType: 'module',
|
|
334
|
+
filename: 'snippet.js',
|
|
335
|
+
})?.code;
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const exported = names.filter((n) => n !== '__proto__');
|
|
340
|
+
if (unsupported || !code || exported.length === 0) return null;
|
|
341
|
+
|
|
342
|
+
// The block lets a top-level `const Note` shadow the built-in parameter of
|
|
343
|
+
// the same name instead of throwing "already declared".
|
|
344
|
+
return `{\n${code}\nreturn { ${exported.join(', ')} };\n}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Evaluate tenant code in a `new Function` with the snippet scope: React, the
|
|
349
|
+
* JSX runtime, the runtime key guard and the built-in components. The ONE
|
|
350
|
+
* place both snippet compile paths build that function, so the strict-mode
|
|
351
|
+
* prologue and the guard binding cannot drift apart between them.
|
|
352
|
+
*/
|
|
353
|
+
function evaluateSnippetCode(
|
|
354
|
+
code: string,
|
|
355
|
+
builtInNames: string[],
|
|
356
|
+
builtInValues: AnyComponent[]
|
|
357
|
+
): unknown {
|
|
358
|
+
// NOTE: new Function() is intentionally used here to compile user-provided snippets
|
|
359
|
+
const fn = new Function(
|
|
360
|
+
'React',
|
|
361
|
+
'_jsx',
|
|
362
|
+
'_jsxs',
|
|
363
|
+
'_Fragment',
|
|
364
|
+
KEY_GUARD_NAME, // runtime half of mdxEvalGuardPlugin — see mdx-eval-guard.ts
|
|
365
|
+
...builtInNames,
|
|
366
|
+
// STRICT_MODE_PROLOGUE is load-bearing, not hygiene: without it this
|
|
367
|
+
// body is sloppy-mode and `this` is globalThis. See mdx-eval-guard.ts.
|
|
368
|
+
`${STRICT_MODE_PROLOGUE}\n${code}`
|
|
369
|
+
);
|
|
370
|
+
return fn(React, jsxRuntime.jsx, jsxRuntime.jsxs, jsxRuntime.Fragment, guardPropertyKey, ...builtInValues);
|
|
371
|
+
}
|
|
372
|
+
|
|
253
373
|
/**
|
|
254
374
|
* Detect plain-markdown snippet source — Mintlify-style snippet files that
|
|
255
375
|
* contain only markdown text (with optional inline JSX components) and have no
|
|
@@ -359,38 +479,74 @@ async function compileSnippet(
|
|
|
359
479
|
// Transpile JSX to JavaScript
|
|
360
480
|
const transpiled = transpileJsx(source);
|
|
361
481
|
|
|
362
|
-
// Extract exports from transpiled code
|
|
363
|
-
const exportedFunctions = extractExports(transpiled);
|
|
364
|
-
|
|
365
|
-
// Compile each export into an actual React component
|
|
366
482
|
const builtInNames = Object.keys(builtInComponents);
|
|
483
|
+
const builtInValues = builtInNames.map((n) => builtInComponents[n]);
|
|
484
|
+
const exports =
|
|
485
|
+
compileSnippetModule(transpiled, snippetPath, builtInNames, builtInValues) ??
|
|
486
|
+
compileSnippetPerExport(transpiled, snippetPath, builtInNames, builtInValues);
|
|
487
|
+
|
|
488
|
+
const result: CompiledSnippet = {
|
|
489
|
+
exports,
|
|
490
|
+
isClientComponent,
|
|
491
|
+
utilityCandidates: extractUtilityCandidates(source),
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// Cache the result
|
|
495
|
+
snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
496
|
+
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Compile the whole snippet file as one function body, so exports share the
|
|
502
|
+
* file's top-level scope like they do under `jamdesk dev`. Returns null (and
|
|
503
|
+
* the caller falls back to per-export compile) when the shape is unsupported
|
|
504
|
+
* or the top-level code throws on the server, e.g. browser-only code that
|
|
505
|
+
* never ran here before.
|
|
506
|
+
*/
|
|
507
|
+
function compileSnippetModule(
|
|
508
|
+
transpiled: string,
|
|
509
|
+
snippetPath: string,
|
|
510
|
+
builtInNames: string[],
|
|
511
|
+
builtInValues: AnyComponent[]
|
|
512
|
+
): Record<string, AnyComponent> | null {
|
|
513
|
+
const body = toSnippetModuleBody(transpiled);
|
|
514
|
+
if (!body) return null;
|
|
515
|
+
try {
|
|
516
|
+
return evaluateSnippetCode(body, builtInNames, builtInValues) as Record<string, AnyComponent>;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
// An escape attempt caught by the runtime key guard must fail the snippet
|
|
519
|
+
// the way the static guard does, not slip through on the fallback path.
|
|
520
|
+
if (error instanceof MdxEvalGuardError) throw error;
|
|
521
|
+
logger.warn('[ISR Snippet] whole-file compile failed, using per-export compile', {
|
|
522
|
+
snippetPath,
|
|
523
|
+
error: error instanceof Error ? error.message : String(error),
|
|
524
|
+
});
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Pre-2026-10-12 path: each export compiled alone, so it cannot see the
|
|
531
|
+
* file's other top-level names. Kept as the fallback so a snippet that worked
|
|
532
|
+
* before keeps working when the whole-file compile can't be used.
|
|
533
|
+
*/
|
|
534
|
+
function compileSnippetPerExport(
|
|
535
|
+
transpiled: string,
|
|
536
|
+
snippetPath: string,
|
|
537
|
+
builtInNames: string[],
|
|
538
|
+
builtInValues: AnyComponent[]
|
|
539
|
+
): Record<string, AnyComponent> {
|
|
540
|
+
const exportedFunctions = extractExports(transpiled);
|
|
367
541
|
const exports: Record<string, AnyComponent> = {};
|
|
368
542
|
|
|
369
543
|
for (const [name, functionCode] of Object.entries(exportedFunctions)) {
|
|
370
544
|
try {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
'_jsx',
|
|
377
|
-
'_jsxs',
|
|
378
|
-
'_Fragment',
|
|
379
|
-
KEY_GUARD_NAME, // runtime half of mdxEvalGuardPlugin — see mdx-eval-guard.ts
|
|
380
|
-
...builtInNames,
|
|
381
|
-
// STRICT_MODE_PROLOGUE is load-bearing, not hygiene: without it this
|
|
382
|
-
// body is sloppy-mode and `this` is globalThis. See mdx-eval-guard.ts.
|
|
383
|
-
`${STRICT_MODE_PROLOGUE}\nreturn ${functionCode}`
|
|
384
|
-
);
|
|
385
|
-
|
|
386
|
-
const component = createComponent(
|
|
387
|
-
React,
|
|
388
|
-
jsxRuntime.jsx,
|
|
389
|
-
jsxRuntime.jsxs,
|
|
390
|
-
jsxRuntime.Fragment,
|
|
391
|
-
guardPropertyKey,
|
|
392
|
-
...builtInNames.map((n) => builtInComponents[n])
|
|
393
|
-
);
|
|
545
|
+
const component = evaluateSnippetCode(
|
|
546
|
+
`return ${functionCode}`,
|
|
547
|
+
builtInNames,
|
|
548
|
+
builtInValues
|
|
549
|
+
) as AnyComponent;
|
|
394
550
|
|
|
395
551
|
exports[name] = component;
|
|
396
552
|
} catch (error) {
|
|
@@ -402,16 +558,7 @@ async function compileSnippet(
|
|
|
402
558
|
}
|
|
403
559
|
}
|
|
404
560
|
|
|
405
|
-
|
|
406
|
-
exports,
|
|
407
|
-
isClientComponent,
|
|
408
|
-
utilityCandidates: extractUtilityCandidates(source),
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
// Cache the result
|
|
412
|
-
snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
413
|
-
|
|
414
|
-
return result;
|
|
561
|
+
return exports;
|
|
415
562
|
}
|
|
416
563
|
|
|
417
564
|
/**
|
|
@@ -34,7 +34,9 @@ export async function filterNavigationForViewer(
|
|
|
34
34
|
const secret = process.env.EDGE_AUTH_SECRET;
|
|
35
35
|
const cookieHeader = headersList.get('cookie') || '';
|
|
36
36
|
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)jd_auth_${slug}=([^;]+)`));
|
|
37
|
-
|
|
37
|
+
// Locked (jwt on, no signing key): no session can be valid, so the viewer
|
|
38
|
+
// has no groups and the nav over-hides like it does for anonymous viewers.
|
|
39
|
+
if (secret && match && !auth.locked) {
|
|
38
40
|
const host = headersList.get('x-jamdesk-forwarded-host') || headersList.get('host') || '';
|
|
39
41
|
try {
|
|
40
42
|
const v = await verifyAuthCookie(match[1], { slug, host, version: auth.version }, secret);
|
|
@@ -39,6 +39,50 @@ async function writeIfChanged(filePath, content) {
|
|
|
39
39
|
return true; // Written
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Generated file names must be unique IGNORING case. On a case-insensitive disk
|
|
44
|
+
* (the macOS default) exports `brandColor` and `BrandColor` would be one file:
|
|
45
|
+
* the second write overwrote the first and Turbopack could not resolve one of
|
|
46
|
+
* the imports. Each component (`identity` = dir + name) owns one file name; a
|
|
47
|
+
* different component that wants a name already taken, in any case, gets a
|
|
48
|
+
* `.2`, `.3`, ... suffix. A dot cannot clash with the `__` directory separator
|
|
49
|
+
* or with a PascalCase base name. Claiming again with the same identity
|
|
50
|
+
* returns the same file name, so the same-name dedupe below still works.
|
|
51
|
+
*/
|
|
52
|
+
function createFileNameClaimer() {
|
|
53
|
+
const owners = new Map(); // lowercased file name -> identity that owns it
|
|
54
|
+
const byIdentity = new Map(); // identity -> file name
|
|
55
|
+
return function claimFileName(desired, identity) {
|
|
56
|
+
if (byIdentity.has(identity)) return byIdentity.get(identity);
|
|
57
|
+
const stem = desired.replace(/\.tsx$/, '');
|
|
58
|
+
for (let n = 1; ; n++) {
|
|
59
|
+
const candidate = n === 1 ? desired : `${stem}.${n}.tsx`;
|
|
60
|
+
if (!owners.has(candidate.toLowerCase())) {
|
|
61
|
+
owners.set(candidate.toLowerCase(), identity);
|
|
62
|
+
byIdentity.set(identity, candidate);
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Delete files that differ from `fileName` only by case before writing it.
|
|
71
|
+
* Names are unique ignoring case within a run (createFileNameClaimer), so any
|
|
72
|
+
* such file is left over from an earlier run. Without this, a case-insensitive
|
|
73
|
+
* disk keeps the OLD casing (writeIfChanged skips identical content), and the
|
|
74
|
+
* case-sensitive stale cleanup then deletes what is really the live file.
|
|
75
|
+
*/
|
|
76
|
+
async function removeCaseVariants(dir, fileName, existingNames) {
|
|
77
|
+
for (let i = existingNames.length - 1; i >= 0; i--) {
|
|
78
|
+
const name = existingNames[i];
|
|
79
|
+
if (name !== fileName && name.toLowerCase() === fileName.toLowerCase()) {
|
|
80
|
+
await fs.remove(path.join(dir, name));
|
|
81
|
+
existingNames.splice(i, 1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
42
86
|
// Parse command line arguments
|
|
43
87
|
const args = process.argv.slice(2);
|
|
44
88
|
const projectArgIndex = args.indexOf('--project');
|
|
@@ -359,6 +403,8 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
359
403
|
|
|
360
404
|
// Track all components and their files
|
|
361
405
|
const componentInfo = []; // { name, fileName, needsClient }
|
|
406
|
+
const claimFileName = createFileNameClaimer();
|
|
407
|
+
const namesOnDisk = fs.existsSync(generatedDir) ? await fs.readdir(generatedDir) : [];
|
|
362
408
|
|
|
363
409
|
for (const snippetFile of snippetFiles) {
|
|
364
410
|
const { file, dir, fullPath: filePath } = snippetFile;
|
|
@@ -381,11 +427,12 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
381
427
|
for (const exportName of exportNames) {
|
|
382
428
|
// Use dir prefix in filename only (to avoid file collisions)
|
|
383
429
|
const filePrefix = dir ? `${dir.replace(/\//g, '__')}__` : '';
|
|
384
|
-
const fileName = `${filePrefix}${exportName}.tsx
|
|
430
|
+
const fileName = claimFileName(`${filePrefix}${exportName}.tsx`, `${dir}|${exportName}`);
|
|
385
431
|
const componentFile = generateComponentFile(exportName, processedCode, needsClient, false);
|
|
386
432
|
|
|
387
433
|
const filePath = path.join(generatedDir, fileName);
|
|
388
434
|
generatedFiles.add(fileName);
|
|
435
|
+
await removeCaseVariants(generatedDir, fileName, namesOnDisk);
|
|
389
436
|
const written = await writeIfChanged(filePath, componentFile);
|
|
390
437
|
// Track with directory for alias mapping
|
|
391
438
|
componentInfo.push({
|
|
@@ -406,11 +453,12 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
406
453
|
const needsClient = needsClientDirective(wrappedCode);
|
|
407
454
|
// Use dir prefix in filename only (double underscore to avoid collisions)
|
|
408
455
|
const filePrefix = dir ? `${dir.replace(/\//g, '__')}__` : '';
|
|
409
|
-
const fileName = `${filePrefix}${baseName}.tsx
|
|
456
|
+
const fileName = claimFileName(`${filePrefix}${baseName}.tsx`, `${dir}|${baseName}`);
|
|
410
457
|
|
|
411
458
|
const componentFile = generateComponentFile(baseName, wrappedCode, needsClient, true);
|
|
412
459
|
const filePath = path.join(generatedDir, fileName);
|
|
413
460
|
generatedFiles.add(fileName);
|
|
461
|
+
await removeCaseVariants(generatedDir, fileName, namesOnDisk);
|
|
414
462
|
const written = await writeIfChanged(filePath, componentFile);
|
|
415
463
|
componentInfo.push({
|
|
416
464
|
name: baseName,
|
|
@@ -458,14 +506,22 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
458
506
|
const dedupedComponentInfo = [...seenComponentKeys.values()];
|
|
459
507
|
|
|
460
508
|
// Build unique import names for each component (prefixed with dir if needed)
|
|
509
|
+
// Identifiers in the index must be unique too: in a subdirectory, `brandColor`
|
|
510
|
+
// and `BrandColor` both become `XBrandColor`, a duplicate import that breaks
|
|
511
|
+
// the whole index. Later duplicates get a `_2`, `_3`, ... suffix.
|
|
512
|
+
const usedImportNames = new Set();
|
|
461
513
|
const importInfo = dedupedComponentInfo.map(c => {
|
|
462
514
|
const prefix = c.dir ? toPascalCase(c.dir.replace(/\//g, '-')) : '';
|
|
463
|
-
const
|
|
515
|
+
const prefixed = prefix + c.name.charAt(0).toUpperCase() + c.name.slice(1);
|
|
516
|
+
const baseName = c.dir ? prefixed : c.name; // Only add prefix if in subdirectory
|
|
517
|
+
let uniqueName = baseName;
|
|
518
|
+
for (let n = 2; usedImportNames.has(uniqueName); n++) uniqueName = `${baseName}_${n}`;
|
|
519
|
+
usedImportNames.add(uniqueName);
|
|
464
520
|
// Strip the .tsx extension from fileName to get the import path
|
|
465
521
|
const importPath = c.fileName.replace(/\.tsx$/, '');
|
|
466
522
|
return {
|
|
467
523
|
...c,
|
|
468
|
-
uniqueName
|
|
524
|
+
uniqueName,
|
|
469
525
|
importPath
|
|
470
526
|
};
|
|
471
527
|
});
|
|
@@ -3264,9 +3264,9 @@
|
|
|
3264
3264
|
}
|
|
3265
3265
|
},
|
|
3266
3266
|
"node_modules/dompurify": {
|
|
3267
|
-
"version": "3.4.
|
|
3268
|
-
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.
|
|
3269
|
-
"integrity": "sha512-
|
|
3267
|
+
"version": "3.4.17",
|
|
3268
|
+
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
|
|
3269
|
+
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
|
|
3270
3270
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
3271
3271
|
"optionalDependencies": {
|
|
3272
3272
|
"@types/trusted-types": "^2.0.7"
|