jamdesk 1.1.204 → 1.1.205
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__/unit/deploy.test.js +106 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -1
- package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts +22 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts.map +1 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.js +193 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.js.map +1 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.d.ts +2 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.d.ts.map +1 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.js +77 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.js.map +1 -0
- package/dist/commands/deploy.js +16 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +5 -0
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +49 -0
- package/dist/lib/docs-config.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +1 -7
- package/vendored/app/(jd-system)/jd-inactive/page.tsx +2 -4
- package/vendored/app/(unlock)/jd/auth/callback/CallbackClient.tsx +33 -0
- package/vendored/app/(unlock)/jd/auth/callback/page.tsx +15 -0
- package/vendored/app/(unlock)/jd/unlock/page.tsx +29 -7
- package/vendored/app/api/jd/auth/callback/route.ts +103 -0
- package/vendored/app/api/jd/auth/logout/route.ts +38 -0
- package/vendored/app/api/jd/auth/me/route.ts +52 -0
- package/vendored/app/api/jd/unlock/route.ts +26 -2
- package/vendored/app/layout.tsx +7 -0
- package/vendored/components/mdx/OpenApiEndpoint.tsx +4 -0
- package/vendored/components/navigation/Header.tsx +5 -0
- package/vendored/components/navigation/LogoutButton.tsx +41 -0
- package/vendored/lib/auth-resolver.ts +125 -50
- package/vendored/lib/cached-redis.ts +28 -3
- package/vendored/lib/docs-types.ts +17 -1
- package/vendored/lib/glob-match.ts +54 -0
- package/vendored/lib/jwt-auth-config.ts +37 -0
- package/vendored/lib/jwt-session.ts +98 -0
- package/vendored/lib/mdx-inline-components.ts +1 -1
- package/vendored/lib/middleware-helpers.ts +11 -6
- package/vendored/lib/nav-group-filter.ts +81 -0
- package/vendored/lib/public-paths-resolver.ts +164 -7
- package/vendored/lib/r2-public-storage-guard.ts +26 -4
- package/vendored/lib/redis.ts +20 -2
- package/vendored/lib/render-doc-page.tsx +74 -13
- package/vendored/lib/root-page-slug.ts +20 -1
- package/vendored/lib/same-origin.ts +38 -0
- package/vendored/lib/sanitize-from.ts +31 -1
- package/vendored/lib/session-hint.ts +11 -0
- package/vendored/lib/snippet-compiler-isr.ts +1 -1
- package/vendored/lib/snippet-loader-isr.ts +1 -1
- package/vendored/lib/validate-config.ts +14 -0
- package/vendored/lib/viewer-navigation.ts +45 -0
- package/vendored/lib/visibility.ts +65 -0
- package/vendored/schema/docs-schema.json +25 -0
- package/vendored/shared/auth-cookie.ts +98 -5
- package/vendored/workspace-package-lock.json +73 -63
|
@@ -96,7 +96,7 @@ import { findOperation, deriveOperationDescription, collectLocalSpecPaths } from
|
|
|
96
96
|
import { extractLanguageFromPath, resolveLocaleFromPath } from '@/lib/language-utils';
|
|
97
97
|
import { parseLocalePath } from '@/lib/locale-fallback';
|
|
98
98
|
import { DocsLocaleProvider } from '@/components/mdx/docs-locale-context';
|
|
99
|
-
import { needsSlugRewrite, resolveSlug } from '@/lib/root-page-slug';
|
|
99
|
+
import { needsSlugRewrite, resolveMarkdownFallbackPath, resolveSlug } from '@/lib/root-page-slug';
|
|
100
100
|
import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
|
|
101
101
|
import { squeezeToPageDescription } from '@/lib/openapi/squeeze-page-description';
|
|
102
102
|
import { ApiEndpoint } from '@/components/mdx/ApiEndpoint';
|
|
@@ -106,6 +106,7 @@ import { withApiSpecDownload } from '@/lib/api-spec-menu-gate';
|
|
|
106
106
|
import { SnippetComponents } from '@/components/snippets/ProjectSnippets';
|
|
107
107
|
import { EmailSubscribe } from '@/components/mdx/EmailSubscribe';
|
|
108
108
|
import { resolveAutoNewsletter, mdxHasEmailSubscribe } from '@/lib/email-subscribe-autoplacement';
|
|
109
|
+
import { filterNavigationForViewer } from '@/lib/viewer-navigation';
|
|
109
110
|
|
|
110
111
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE';
|
|
111
112
|
|
|
@@ -432,17 +433,38 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
|
|
|
432
433
|
// Bare `/` or `/<lang>` roots render the first nav page but keep the alias URL;
|
|
433
434
|
// suppress hreflang on them (passed to buildSeoMetadata — see its isRootAlias
|
|
434
435
|
// doc for why).
|
|
435
|
-
const
|
|
436
|
-
const slug =
|
|
436
|
+
const isAliasSlug = needsSlugRewrite(normalizedSlug);
|
|
437
|
+
const slug = isAliasSlug
|
|
437
438
|
? resolveSlug(normalizedSlug, await configP)
|
|
438
439
|
: normalizedSlug;
|
|
439
|
-
const
|
|
440
|
+
const literalPath = slug.join('/');
|
|
440
441
|
|
|
441
|
-
const [
|
|
442
|
-
loader.getContent(
|
|
442
|
+
const [literalContents, config] = await Promise.all([
|
|
443
|
+
loader.getContent(literalPath).catch(() => null),
|
|
443
444
|
configP,
|
|
444
445
|
]);
|
|
445
446
|
|
|
447
|
+
// `<prefix>/index` is the site root spelled out — the proxy collapses `/` and
|
|
448
|
+
// `/index.md` to the page path `index`, so the markdown export has always
|
|
449
|
+
// answered it, while HTML 404'd because `/docs` yields the empty slug and
|
|
450
|
+
// `/docs/index` yields `['index']`. That asymmetry let an agent cite a URL
|
|
451
|
+
// that 404s for a human. Resolve it the way the .md route does — LITERAL
|
|
452
|
+
// FIRST, fallback second — so a tenant with a real `index.mdx` still reaches
|
|
453
|
+
// it here, and only a tenant without one falls through to the first nav page.
|
|
454
|
+
// A miss on any other path stays a 404: resolveMarkdownFallbackPath returns
|
|
455
|
+
// null for everything except the root sentinel and a bare language code.
|
|
456
|
+
const rootFallbackPath = literalContents
|
|
457
|
+
? null
|
|
458
|
+
: resolveMarkdownFallbackPath(literalPath, config);
|
|
459
|
+
const rootFallbackContents = rootFallbackPath
|
|
460
|
+
? await loader.getContent(rootFallbackPath).catch(() => null)
|
|
461
|
+
: null;
|
|
462
|
+
|
|
463
|
+
const pageContents = literalContents ?? rootFallbackContents;
|
|
464
|
+
const pagePath = rootFallbackContents ? rootFallbackPath! : literalPath;
|
|
465
|
+
// A fallback render IS a root alias: same page, same hreflang/JSON-LD shape.
|
|
466
|
+
const isRootAlias = isAliasSlug || rootFallbackContents !== null;
|
|
467
|
+
|
|
446
468
|
// Untranslated locale page → serve the English page's metadata for it (see
|
|
447
469
|
// lib/locale-fallback). This block MUST stay in lockstep with the identical
|
|
448
470
|
// one in renderDocPage: they are two Next entry points over ONE URL, and a
|
|
@@ -556,24 +578,46 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
556
578
|
const highlighterP = getHighlighter();
|
|
557
579
|
|
|
558
580
|
const normalizedSlug = normalizeSlugForContent(slugInput || [], hostAtDocs);
|
|
559
|
-
const
|
|
560
|
-
const slug =
|
|
581
|
+
const isAliasSlug = needsSlugRewrite(normalizedSlug);
|
|
582
|
+
const slug = isAliasSlug
|
|
561
583
|
? resolveSlug(normalizedSlug, await configP)
|
|
562
584
|
: normalizedSlug;
|
|
563
|
-
const
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
loader.getContent(pagePath).catch(() => null),
|
|
585
|
+
const literalPath = slug.join('/');
|
|
586
|
+
const [literalContents, loadedConfig, highlighter] = await Promise.all([
|
|
587
|
+
loader.getContent(literalPath).catch(() => null),
|
|
567
588
|
configP,
|
|
568
589
|
highlighterP,
|
|
569
590
|
]);
|
|
570
591
|
|
|
592
|
+
// `<prefix>/index` is the site root spelled out — the proxy collapses `/` and
|
|
593
|
+
// `/index.md` to the page path `index`, so the markdown export has always
|
|
594
|
+
// answered it, while HTML 404'd because `/docs` yields the empty slug and
|
|
595
|
+
// `/docs/index` yields `['index']`. That asymmetry let an agent cite a URL
|
|
596
|
+
// that 404s for a human. Resolve it the way the .md route does — LITERAL
|
|
597
|
+
// FIRST, fallback second — so a tenant with a real `index.mdx` still reaches
|
|
598
|
+
// it here, and only a tenant without one falls through to the first nav page.
|
|
599
|
+
// A miss on any other path stays a 404: resolveMarkdownFallbackPath returns
|
|
600
|
+
// null for everything except the root sentinel and a bare language code.
|
|
601
|
+
// Mirror of the block in buildDocMetadata — the two entry points must reach
|
|
602
|
+
// the same verdict on the same URL, or metadata and body disagree.
|
|
603
|
+
const rootFallbackPath = literalContents
|
|
604
|
+
? null
|
|
605
|
+
: resolveMarkdownFallbackPath(literalPath, loadedConfig);
|
|
606
|
+
const rootFallbackContents = rootFallbackPath
|
|
607
|
+
? await loader.getContent(rootFallbackPath).catch(() => null)
|
|
608
|
+
: null;
|
|
609
|
+
|
|
610
|
+
const pageContents = literalContents ?? rootFallbackContents;
|
|
611
|
+
const pagePath = rootFallbackContents ? rootFallbackPath! : literalPath;
|
|
612
|
+
const isRootAlias = isAliasSlug || rootFallbackContents !== null;
|
|
613
|
+
const currentLang = extractLanguageFromPath(`/${pagePath}`);
|
|
614
|
+
|
|
571
615
|
// Untranslated locale page → serve the English body with a notice instead of
|
|
572
616
|
// 404ing. Mirror of the block in buildDocMetadata — see the comment there for
|
|
573
617
|
// why both entry points must reach the same verdict on the same URL.
|
|
574
618
|
const localeFallback = pageContents
|
|
575
619
|
? null
|
|
576
|
-
: parseLocalePath(pagePath, fallbackLocales(
|
|
620
|
+
: parseLocalePath(pagePath, fallbackLocales(loadedConfig));
|
|
577
621
|
const fileContents = pageContents ?? (localeFallback
|
|
578
622
|
? await loader.getContent(localeFallback.englishPath).catch(() => null)
|
|
579
623
|
: null);
|
|
@@ -590,6 +634,17 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
590
634
|
});
|
|
591
635
|
}
|
|
592
636
|
|
|
637
|
+
// Same viewer filter the root layout applies: this config feeds the
|
|
638
|
+
// breadcrumb and the prev/next pager, which would otherwise name a
|
|
639
|
+
// group-restricted neighbour the edge 404s for this viewer.
|
|
640
|
+
let config = loadedConfig;
|
|
641
|
+
if (isIsr && projectSlug && requestHeaders && config.auth?.jwt?.enabled) {
|
|
642
|
+
config = {
|
|
643
|
+
...config,
|
|
644
|
+
navigation: await filterNavigationForViewer(projectSlug, config.navigation, requestHeaders),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
593
648
|
const parsed = parseFrontmatter(fileContents);
|
|
594
649
|
const data = parsed.data as FrontmatterData;
|
|
595
650
|
const rawContent = parsed.content;
|
|
@@ -1072,6 +1127,10 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1072
1127
|
|
|
1073
1128
|
const resolvedMdxAuth = resolveAuth(mdxEndpointData, config);
|
|
1074
1129
|
const resolvedOpenApiAuth = resolveAuth(openApiEndpointData, config);
|
|
1130
|
+
// Drives the playground's post-mount /_jd/api/auth/me fetch that pre-fills
|
|
1131
|
+
// auth/params from the signed-in session — only worth the round-trip when
|
|
1132
|
+
// JWT auth is actually opted into for this project.
|
|
1133
|
+
const jwtAuthEnabled = config.auth?.jwt?.enabled === true;
|
|
1075
1134
|
|
|
1076
1135
|
// Pass the RAW MDX content — preprocessMdx() runs stripSnippetImports(),
|
|
1077
1136
|
// which would erase the `import X from '/snippets/...'` lines that the
|
|
@@ -1125,6 +1184,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1125
1184
|
serverUrl={fallbackServerUrl}
|
|
1126
1185
|
proxyEnabled={proxyEnabled}
|
|
1127
1186
|
languages={config.api?.examples?.languages}
|
|
1187
|
+
jwtAuthEnabled={jwtAuthEnabled}
|
|
1128
1188
|
/>
|
|
1129
1189
|
) : (
|
|
1130
1190
|
<ApiEndpoint
|
|
@@ -1147,6 +1207,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1147
1207
|
proxyEnabled={proxyEnabled}
|
|
1148
1208
|
languages={config.api?.examples?.languages}
|
|
1149
1209
|
descriptionAlreadyShown={descriptionAlreadyShown}
|
|
1210
|
+
jwtAuthEnabled={jwtAuthEnabled}
|
|
1150
1211
|
/>
|
|
1151
1212
|
)}
|
|
1152
1213
|
|
|
@@ -41,7 +41,26 @@ export function findFirstPage(config: DocsConfig, lang?: string): string {
|
|
|
41
41
|
return result ? result.replace(/^\//, '') : 'introduction';
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* `/` or a bare `/<lang>` — the two URL shapes that render a page they do not name.
|
|
46
|
+
*
|
|
47
|
+
* DELIBERATE, do not "tighten" this to the tenant's declared languages: the test
|
|
48
|
+
* is against the GLOBAL language list, so all ~34 codes alias the first nav page
|
|
49
|
+
* on every tenant, including ones that declare no languages at all
|
|
50
|
+
* (`acme.jamdesk.app/ru` and `/ru.md` both serve the English home page). That is
|
|
51
|
+
* `findFirstPage`'s documented contract — an undeclared language block falls
|
|
52
|
+
* through to the whole nav — and HTML has behaved this way since alias URLs were
|
|
53
|
+
* introduced; the markdown export was brought into line with it on purpose, so
|
|
54
|
+
* the two surfaces agree.
|
|
55
|
+
*
|
|
56
|
+
* Gating on `config.navigation.languages` would 404 a bookmarked `/en` on every
|
|
57
|
+
* monolingual tenant, and would have to change all three consumers at once
|
|
58
|
+
* (render-doc-page's two entry points and resolveMarkdownFallbackPath, which
|
|
59
|
+
* feeds both markdown routes). The cost of leaving it is agent-facing only —
|
|
60
|
+
* ~34 phantom URLs per tenant that all return the same page — and `.md` carries
|
|
61
|
+
* `noindex, nofollow`, so there is no search exposure. Revisit only with an
|
|
62
|
+
* owner decision, not as a cleanup.
|
|
63
|
+
*/
|
|
45
64
|
export function needsSlugRewrite(slug: string[]): boolean {
|
|
46
65
|
return slug.length === 0 || (slug.length === 1 && isValidLanguageCode(slug[0]));
|
|
47
66
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin guard for cookie-minting POST routes (JWT callback, password
|
|
3
|
+
* unlock). Both routes set a session cookie from a POST body, and a browser
|
|
4
|
+
* will happily send that POST cross-site — a page on evil.example.com can
|
|
5
|
+
* submit a text/plain form or fetch() to <docs-host>/api/jd/auth/callback,
|
|
6
|
+
* and the response's Set-Cookie lands in the victim's jar (login-CSRF). With
|
|
7
|
+
* the JWT route that means an attacker-chosen session, including groups and
|
|
8
|
+
* API-playground inputs the playground then auto-fills.
|
|
9
|
+
*
|
|
10
|
+
* Decision, in order:
|
|
11
|
+
* 1. `Sec-Fetch-Site` when present is authoritative (browsers set it and
|
|
12
|
+
* pages cannot forge it): `same-origin` and `none` (user-typed
|
|
13
|
+
* navigation) pass; `cross-site` and `same-site` fail — a sibling
|
|
14
|
+
* *.jamdesk.app subdomain is another tenant, so same-site is NOT ok.
|
|
15
|
+
* 2. Otherwise `Origin` when present must match the request host —
|
|
16
|
+
* the customer-forwarded public host first (x-jamdesk-forwarded-host),
|
|
17
|
+
* then Host — case-insensitively. `Origin: null` (sandboxed/opaque
|
|
18
|
+
* contexts) fails.
|
|
19
|
+
* 3. Neither header → allow. Non-browser clients (curl, the smoke harness)
|
|
20
|
+
* send neither, and every browser that can mount the attack sends at
|
|
21
|
+
* least one of them on a cross-site POST.
|
|
22
|
+
*/
|
|
23
|
+
export function isSameOriginRequest(req: Request): boolean {
|
|
24
|
+
const fetchSite = req.headers.get('sec-fetch-site');
|
|
25
|
+
if (fetchSite) {
|
|
26
|
+
return fetchSite === 'same-origin' || fetchSite === 'none';
|
|
27
|
+
}
|
|
28
|
+
const origin = req.headers.get('origin');
|
|
29
|
+
if (!origin) return true;
|
|
30
|
+
let originHost: string;
|
|
31
|
+
try {
|
|
32
|
+
originHost = new URL(origin).host;
|
|
33
|
+
} catch {
|
|
34
|
+
return false; // includes the literal "null"
|
|
35
|
+
}
|
|
36
|
+
const requestHost = req.headers.get('x-jamdesk-forwarded-host') || req.headers.get('host') || '';
|
|
37
|
+
return originHost.toLowerCase() === requestHost.toLowerCase();
|
|
38
|
+
}
|
|
@@ -29,8 +29,38 @@ export function sanitizeFrom(raw: string | null | undefined): string {
|
|
|
29
29
|
// form to catch percent-encoded slashes like /%2F%2Fevil.com → //evil.com.
|
|
30
30
|
const normalized = parsed.pathname + parsed.search + parsed.hash;
|
|
31
31
|
if (parsed.pathname.startsWith('//')) return '/';
|
|
32
|
-
|
|
32
|
+
// The URL parser passes malformed percent-sequences through verbatim
|
|
33
|
+
// ('/%', '/a%2'), where decodeURIComponent throws — if we can't decode we
|
|
34
|
+
// can't rule out an encoded //, so reject rather than 500.
|
|
35
|
+
let decodedPathname: string;
|
|
36
|
+
try {
|
|
37
|
+
decodedPathname = decodeURIComponent(parsed.pathname);
|
|
38
|
+
} catch {
|
|
39
|
+
return '/';
|
|
40
|
+
}
|
|
33
41
|
if (decodedPathname.startsWith('//')) return '/';
|
|
34
42
|
|
|
35
43
|
return normalized;
|
|
36
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Redirect sanitizer for the JWT auth callback. sanitizeFrom does the heavy
|
|
48
|
+
* lifting (same-site path only; rejects CRLF, backslashes, protocol-relative
|
|
49
|
+
* and percent-encoded // forms); this only adds the auth self-loop guards:
|
|
50
|
+
* the callback page itself, and the auth API routes — /api/jd/auth/logout is
|
|
51
|
+
* a GET that clears the cookie, so a crafted `redirect` would log the freshly
|
|
52
|
+
* minted session straight back out. Both the legacy paths and the /_jd
|
|
53
|
+
* aliases are served, so both prefixes are refused.
|
|
54
|
+
*/
|
|
55
|
+
const CALLBACK_REDIRECT_DENY_PREFIXES = ['/jd/auth/', '/_jd/auth/', '/api/jd/auth/', '/_jd/api/auth/'];
|
|
56
|
+
|
|
57
|
+
export function sanitizeCallbackRedirect(raw: unknown): string {
|
|
58
|
+
const path = sanitizeFrom(typeof raw === 'string' ? raw : null);
|
|
59
|
+
// Match on the DECODED path: sanitizeFrom keeps percent-encoding, and the
|
|
60
|
+
// router decodes before matching, so `/%61pi/jd/auth/logout` would reach
|
|
61
|
+
// logout while slipping past a literal prefix check.
|
|
62
|
+
let decoded = path;
|
|
63
|
+
try { decoded = decodeURIComponent(path); } catch { return '/'; }
|
|
64
|
+
if (CALLBACK_REDIRECT_DENY_PREFIXES.some((prefix) => decoded.startsWith(prefix))) return '/';
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `jd_authed=1` hint cookie is set by the JWT callback and cleared by
|
|
3
|
+
* logout. It is client-readable, carries nothing, and has no security value —
|
|
4
|
+
* the signed HttpOnly session cookie behind /_jd/api/auth/me is what's
|
|
5
|
+
* trusted. Its only job is to let client components skip the /me round-trip
|
|
6
|
+
* on anonymous views (every SEO landing and shared link would otherwise pay
|
|
7
|
+
* it). Shared by the header logout affordance and the API playground pre-fill.
|
|
8
|
+
*/
|
|
9
|
+
export function hasJwtSessionHint(cookieString: string): boolean {
|
|
10
|
+
return cookieString.split('; ').includes('jd_authed=1');
|
|
11
|
+
}
|
|
@@ -21,6 +21,7 @@ import JSON5 from 'json5';
|
|
|
21
21
|
import fs from 'fs';
|
|
22
22
|
import path from 'path';
|
|
23
23
|
import { BUILD_SERVICE_DIR } from './paths.js';
|
|
24
|
+
import { validateJwtAuth } from './jwt-auth-config.js';
|
|
24
25
|
|
|
25
26
|
// Types
|
|
26
27
|
export interface DocsConfig {
|
|
@@ -592,6 +593,19 @@ export async function validateConfig(
|
|
|
592
593
|
};
|
|
593
594
|
}
|
|
594
595
|
|
|
596
|
+
// validate-config.ts's local DocsConfig (theme optional, pre-schema-validation)
|
|
597
|
+
// is structurally close to but not assignable to docs-types.ts's DocsConfig
|
|
598
|
+
// (theme required) — validateJwtAuth only reads `config.auth`, which both
|
|
599
|
+
// shapes carry via their `[key: string]: unknown` index signatures.
|
|
600
|
+
const jwtErrors = validateJwtAuth(config as unknown as Parameters<typeof validateJwtAuth>[0]);
|
|
601
|
+
if (jwtErrors.length > 0) {
|
|
602
|
+
return {
|
|
603
|
+
valid: false,
|
|
604
|
+
error: `Invalid docs.json: ${jwtErrors[0]}`,
|
|
605
|
+
errorType: 'config_error',
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
595
609
|
// Try schema validation if available (non-blocking if schema not available)
|
|
596
610
|
try {
|
|
597
611
|
const schema = await getSchema();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DocsConfig } from './docs-types';
|
|
2
|
+
import { resolveAuth } from './auth-resolver';
|
|
3
|
+
import { verifyAuthCookie } from '../shared/auth-cookie';
|
|
4
|
+
import { filterNavigationForGroups } from './nav-group-filter';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drop group-restricted pages the viewer can't access from the navigation
|
|
8
|
+
* before it reaches the client. Server-only (reads Redis + the session
|
|
9
|
+
* cookie). Two consumers must agree: the root layout (sidebar/tabs/header
|
|
10
|
+
* resolve navigation from its config in the browser) and the page render
|
|
11
|
+
* (breadcrumb + prev/next pager). Without this every public page's RSC
|
|
12
|
+
* payload lists the titles and hrefs of pages the edge answers with a 404
|
|
13
|
+
* to out-of-group users — defeating the point of the 404.
|
|
14
|
+
*
|
|
15
|
+
* Group membership comes from the SERVER-VERIFIED v2 session cookie (same
|
|
16
|
+
* check as /_jd/api/auth/me), never the client-readable jd_authed hint.
|
|
17
|
+
* Cookie verification fails closed to "no groups" (over-hides). A resolver
|
|
18
|
+
* failure is different: the group map itself is unavailable, so the
|
|
19
|
+
* navigation is returned unfiltered rather than taking the page down — the
|
|
20
|
+
* edge gate 503s the request on the same outage, so this only renders when
|
|
21
|
+
* the edge and Node runtimes' 15s caches disagree.
|
|
22
|
+
*/
|
|
23
|
+
export async function filterNavigationForViewer(
|
|
24
|
+
slug: string,
|
|
25
|
+
navigation: DocsConfig['navigation'],
|
|
26
|
+
headersList: Headers,
|
|
27
|
+
): Promise<DocsConfig['navigation']> {
|
|
28
|
+
let auth: Awaited<ReturnType<typeof resolveAuth>> = null;
|
|
29
|
+
try { auth = await resolveAuth(slug); } catch { /* group map unavailable: unfiltered */ }
|
|
30
|
+
if (!auth || auth.authType !== 'jwt' || !auth.groupsPaths) return navigation;
|
|
31
|
+
if (Object.keys(auth.groupsPaths).length === 0) return navigation;
|
|
32
|
+
|
|
33
|
+
let userGroups: string[] = [];
|
|
34
|
+
const secret = process.env.EDGE_AUTH_SECRET;
|
|
35
|
+
const cookieHeader = headersList.get('cookie') || '';
|
|
36
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)jd_auth_${slug}=([^;]+)`));
|
|
37
|
+
if (secret && match) {
|
|
38
|
+
const host = headersList.get('x-jamdesk-forwarded-host') || headersList.get('host') || '';
|
|
39
|
+
try {
|
|
40
|
+
const v = await verifyAuthCookie(match[1], { slug, host, version: auth.version }, secret);
|
|
41
|
+
if (v.valid && v.formatVersion === 'v2') userGroups = v.extras?.g ?? [];
|
|
42
|
+
} catch { /* fail closed: no groups */ }
|
|
43
|
+
}
|
|
44
|
+
return filterNavigationForGroups(navigation, auth.groupsPaths, userGroups);
|
|
45
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DocsConfig, NavigationConfig } from './docs-types.js';
|
|
2
2
|
import { RECURSE_KEYS } from './enhance-navigation.js';
|
|
3
|
+
import { MAX_GROUP_NAME_LEN } from './public-paths-resolver.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Result of walking the nav tree for hidden-state classification.
|
|
@@ -120,12 +121,24 @@ export interface VisibilityInputs {
|
|
|
120
121
|
navPaths: Set<string>;
|
|
121
122
|
hiddenNavPaths: Set<string>;
|
|
122
123
|
searchableNavPaths: Set<string>;
|
|
124
|
+
/**
|
|
125
|
+
* Slug (no leading slash, extension stripped — same convention as `path` and
|
|
126
|
+
* `navPaths`/`hiddenNavPaths`) → required groups, from
|
|
127
|
+
* lib/public-paths-resolver.ts's `collectGroupsPaths`. This map is what
|
|
128
|
+
* carries locale inheritance: a translated page (e.g. `fr/admin`) whose own
|
|
129
|
+
* frontmatter lacks `groups` still shows up here because collectGroupsPaths
|
|
130
|
+
* propagates the base page's restriction onto it. Optional/omitted (or
|
|
131
|
+
* empty, as in non-jwt auth modes) preserves the own-frontmatter-only
|
|
132
|
+
* fallback in `isGroupRestricted` below.
|
|
133
|
+
*/
|
|
134
|
+
groupsPaths?: Record<string, string[]>;
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
export interface PageFrontmatterForVisibility {
|
|
126
138
|
hidden?: boolean;
|
|
127
139
|
noindex?: boolean;
|
|
128
140
|
seo?: { noindex?: boolean };
|
|
141
|
+
groups?: unknown;
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
export interface PageVisibility {
|
|
@@ -135,17 +148,60 @@ export interface PageVisibility {
|
|
|
135
148
|
| 'orphan'
|
|
136
149
|
| 'frontmatter-hidden'
|
|
137
150
|
| 'frontmatter-noindex'
|
|
151
|
+
| 'frontmatter-groups'
|
|
138
152
|
| 'nav-hidden'
|
|
139
153
|
| 'override-indexing-all'
|
|
140
154
|
| 'override-indexHiddenPages';
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
/**
|
|
158
|
+
* True when frontmatter carries a real group restriction: an array with at
|
|
159
|
+
* least one VALID entry. Validity mirrors collectGroupsPaths' per-entry
|
|
160
|
+
* filter exactly (typeof === 'string', non-empty, <= MAX_GROUP_NAME_LEN) —
|
|
161
|
+
* so `groups: [123]` or `groups: ['x'.repeat(65)]` is judged unrestricted
|
|
162
|
+
* here too. Without this parity, a page whose only entries are junk would be
|
|
163
|
+
* fail-closed (hidden from search/sitemap/RAG) here while the runtime edge
|
|
164
|
+
* gate (which uses the same filter) fail-opens it as public — a page that's
|
|
165
|
+
* reachable but invisible, not the access-control divergence this guards
|
|
166
|
+
* against being read the other way around.
|
|
167
|
+
*/
|
|
168
|
+
export function hasGroupRestriction(frontmatter: unknown): boolean {
|
|
169
|
+
const groups = (frontmatter as { groups?: unknown } | undefined)?.groups;
|
|
170
|
+
if (!Array.isArray(groups)) return false;
|
|
171
|
+
return groups.some(
|
|
172
|
+
(g): g is string => typeof g === 'string' && g.length > 0 && g.length <= MAX_GROUP_NAME_LEN,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* True when a page is group-restricted, considering BOTH its own frontmatter
|
|
178
|
+
* and locale inheritance. `groupsPaths` (from collectGroupsPaths) carries
|
|
179
|
+
* restrictions forward onto translated variants that lack their own `groups`
|
|
180
|
+
* field — a translated page's frontmatter routinely drops fields the base
|
|
181
|
+
* page had, so checking `frontmatter` alone would silently reopen every
|
|
182
|
+
* localized variant of a restricted page. `path` must already be normalized
|
|
183
|
+
* to the same leading-slash-free convention as `groupsPaths`' keys and the
|
|
184
|
+
* rest of VisibilityInputs (navPaths etc.) — callers own that normalization.
|
|
185
|
+
*/
|
|
186
|
+
export function isGroupRestricted(
|
|
187
|
+
path: string,
|
|
188
|
+
frontmatter: unknown,
|
|
189
|
+
groupsPaths?: Record<string, string[]>,
|
|
190
|
+
): boolean {
|
|
191
|
+
if (hasGroupRestriction(frontmatter)) return true;
|
|
192
|
+
const inherited = groupsPaths?.[path];
|
|
193
|
+
return Array.isArray(inherited) && inherited.length > 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
/**
|
|
144
197
|
* The single function that decides whether a page is in the sitemap / llms.txt /
|
|
145
198
|
* llms-full.txt / search index. Same logic for all four artifacts.
|
|
146
199
|
*
|
|
147
200
|
* Excluded when ANY of:
|
|
148
201
|
* - frontmatter.noindex or frontmatter.seo.noindex
|
|
202
|
+
* - the page is group-restricted per isGroupRestricted (own frontmatter.groups
|
|
203
|
+
* OR inherited via inputs.groupsPaths — see that function) (access control —
|
|
204
|
+
* NEVER overrideable, not even by indexHiddenPages or indexing:'all')
|
|
149
205
|
* - frontmatter.hidden (UNLESS indexHiddenPages or indexing:'all')
|
|
150
206
|
* - path not in navigation (orphan) AND indexing !== 'all'
|
|
151
207
|
* - path in hiddenNavPaths AND NOT in searchableNavPaths (UNLESS indexHiddenPages or indexing:'all')
|
|
@@ -168,6 +224,15 @@ export function computePageVisibility(
|
|
|
168
224
|
return { inArtifacts: false, reason: 'frontmatter-noindex' };
|
|
169
225
|
}
|
|
170
226
|
|
|
227
|
+
// Group-restricted pages are access-controlled content: never list them in
|
|
228
|
+
// sitemap/llms/search artifacts, regardless of seo indexing overrides —
|
|
229
|
+
// those overrides express SEO preference, not permission to leak titles.
|
|
230
|
+
// isGroupRestricted also catches locale-inherited restriction (a translated
|
|
231
|
+
// page whose own frontmatter dropped `groups`) via inputs.groupsPaths.
|
|
232
|
+
if (isGroupRestricted(path, frontmatter, inputs.groupsPaths)) {
|
|
233
|
+
return { inArtifacts: false, reason: 'frontmatter-groups' };
|
|
234
|
+
}
|
|
235
|
+
|
|
171
236
|
// frontmatter.hidden wins over everything except noindex (checked above).
|
|
172
237
|
// Mintlify spec rule #7: page-level hidden always wins over parent searchable.
|
|
173
238
|
if (frontmatter.hidden === true && !override) {
|
|
@@ -1694,6 +1694,31 @@
|
|
|
1694
1694
|
"uniqueItems": true
|
|
1695
1695
|
}
|
|
1696
1696
|
}
|
|
1697
|
+
},
|
|
1698
|
+
"jwt": {
|
|
1699
|
+
"type": "object",
|
|
1700
|
+
"description": "JWT authentication. Generate the signing key in the Jamdesk dashboard; this block controls opt-in, your login URL, and public path exceptions.",
|
|
1701
|
+
"additionalProperties": false,
|
|
1702
|
+
"properties": {
|
|
1703
|
+
"enabled": {
|
|
1704
|
+
"type": "boolean",
|
|
1705
|
+
"description": "Require JWT authentication for the whole site."
|
|
1706
|
+
},
|
|
1707
|
+
"loginUrl": {
|
|
1708
|
+
"type": "string",
|
|
1709
|
+
"format": "uri",
|
|
1710
|
+
"description": "Absolute https:// URL of your login flow. Visitors are redirected here with ?redirect=<path>."
|
|
1711
|
+
},
|
|
1712
|
+
"public": {
|
|
1713
|
+
"type": "array",
|
|
1714
|
+
"maxItems": 100,
|
|
1715
|
+
"items": {
|
|
1716
|
+
"type": "string",
|
|
1717
|
+
"pattern": "^/[A-Za-z0-9._*/-]+$"
|
|
1718
|
+
},
|
|
1719
|
+
"description": "Paths or globs that bypass authentication. Supports '*' and '**'."
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1697
1722
|
}
|
|
1698
1723
|
}
|
|
1699
1724
|
},
|