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.
Files changed (58) hide show
  1. package/dist/__tests__/unit/deploy.test.js +106 -0
  2. package/dist/__tests__/unit/deploy.test.js.map +1 -1
  3. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts +22 -0
  4. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/jwt-auth-config-drift.test.js +193 -0
  6. package/dist/__tests__/unit/jwt-auth-config-drift.test.js.map +1 -0
  7. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts +2 -0
  8. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts.map +1 -0
  9. package/dist/__tests__/unit/validate-jwt-auth.test.js +77 -0
  10. package/dist/__tests__/unit/validate-jwt-auth.test.js.map +1 -0
  11. package/dist/commands/deploy.js +16 -0
  12. package/dist/commands/deploy.js.map +1 -1
  13. package/dist/lib/deps.d.ts.map +1 -1
  14. package/dist/lib/deps.js +5 -0
  15. package/dist/lib/deps.js.map +1 -1
  16. package/dist/lib/docs-config.d.ts +1 -0
  17. package/dist/lib/docs-config.d.ts.map +1 -1
  18. package/dist/lib/docs-config.js +49 -0
  19. package/dist/lib/docs-config.js.map +1 -1
  20. package/package.json +1 -1
  21. package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +1 -7
  22. package/vendored/app/(jd-system)/jd-inactive/page.tsx +2 -4
  23. package/vendored/app/(unlock)/jd/auth/callback/CallbackClient.tsx +33 -0
  24. package/vendored/app/(unlock)/jd/auth/callback/page.tsx +15 -0
  25. package/vendored/app/(unlock)/jd/unlock/page.tsx +29 -7
  26. package/vendored/app/api/jd/auth/callback/route.ts +103 -0
  27. package/vendored/app/api/jd/auth/logout/route.ts +38 -0
  28. package/vendored/app/api/jd/auth/me/route.ts +52 -0
  29. package/vendored/app/api/jd/unlock/route.ts +26 -2
  30. package/vendored/app/layout.tsx +7 -0
  31. package/vendored/components/mdx/OpenApiEndpoint.tsx +4 -0
  32. package/vendored/components/navigation/Header.tsx +5 -0
  33. package/vendored/components/navigation/LogoutButton.tsx +41 -0
  34. package/vendored/lib/auth-resolver.ts +125 -50
  35. package/vendored/lib/cached-redis.ts +28 -3
  36. package/vendored/lib/docs-types.ts +17 -1
  37. package/vendored/lib/glob-match.ts +54 -0
  38. package/vendored/lib/jwt-auth-config.ts +37 -0
  39. package/vendored/lib/jwt-session.ts +98 -0
  40. package/vendored/lib/mdx-inline-components.ts +1 -1
  41. package/vendored/lib/middleware-helpers.ts +11 -6
  42. package/vendored/lib/nav-group-filter.ts +81 -0
  43. package/vendored/lib/public-paths-resolver.ts +164 -7
  44. package/vendored/lib/r2-public-storage-guard.ts +26 -4
  45. package/vendored/lib/redis.ts +20 -2
  46. package/vendored/lib/render-doc-page.tsx +74 -13
  47. package/vendored/lib/root-page-slug.ts +20 -1
  48. package/vendored/lib/same-origin.ts +38 -0
  49. package/vendored/lib/sanitize-from.ts +31 -1
  50. package/vendored/lib/session-hint.ts +11 -0
  51. package/vendored/lib/snippet-compiler-isr.ts +1 -1
  52. package/vendored/lib/snippet-loader-isr.ts +1 -1
  53. package/vendored/lib/validate-config.ts +14 -0
  54. package/vendored/lib/viewer-navigation.ts +45 -0
  55. package/vendored/lib/visibility.ts +65 -0
  56. package/vendored/schema/docs-schema.json +25 -0
  57. package/vendored/shared/auth-cookie.ts +98 -5
  58. 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 isRootAlias = needsSlugRewrite(normalizedSlug);
436
- const slug = isRootAlias
436
+ const isAliasSlug = needsSlugRewrite(normalizedSlug);
437
+ const slug = isAliasSlug
437
438
  ? resolveSlug(normalizedSlug, await configP)
438
439
  : normalizedSlug;
439
- const pagePath = slug.join('/');
440
+ const literalPath = slug.join('/');
440
441
 
441
- const [pageContents, config] = await Promise.all([
442
- loader.getContent(pagePath).catch(() => null),
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 isRootAlias = needsSlugRewrite(normalizedSlug);
560
- const slug = isRootAlias
581
+ const isAliasSlug = needsSlugRewrite(normalizedSlug);
582
+ const slug = isAliasSlug
561
583
  ? resolveSlug(normalizedSlug, await configP)
562
584
  : normalizedSlug;
563
- const pagePath = slug.join('/');
564
- const currentLang = extractLanguageFromPath(`/${pagePath}`);
565
- const [pageContents, config, highlighter] = await Promise.all([
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(config));
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
- /** `/` or a bare `/<lang>` — the two URL shapes that render a page they do not name. */
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
- const decodedPathname = decodeURIComponent(parsed.pathname);
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
+ }
@@ -66,7 +66,7 @@ export async function compileSnippetIsr(
66
66
  filename: snippetPath,
67
67
  });
68
68
 
69
- if (!transpiled.code) {
69
+ if (!transpiled?.code) {
70
70
  throw new Error(`Failed to compile snippet: ${snippetPath}`);
71
71
  }
72
72
 
@@ -194,7 +194,7 @@ export function transpileJsx(source: string): string {
194
194
  filename: 'snippet.tsx',
195
195
  });
196
196
 
197
- if (!result.code) {
197
+ if (!result?.code) {
198
198
  throw new Error('Babel transpilation failed');
199
199
  }
200
200
 
@@ -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
  },