jamdesk 1.1.203 → 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 (112) hide show
  1. package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -1
  2. package/dist/__tests__/integration/init.integration.test.js.map +1 -1
  3. package/dist/__tests__/integration/prepublish.integration.test.js.map +1 -1
  4. package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
  5. package/dist/__tests__/unit/banner-art-sync.test.js.map +1 -1
  6. package/dist/__tests__/unit/deploy-templates.test.js.map +1 -1
  7. package/dist/__tests__/unit/deploy.test.js +106 -0
  8. package/dist/__tests__/unit/deploy.test.js.map +1 -1
  9. package/dist/__tests__/unit/deprecated-components-sync.test.js.map +1 -1
  10. package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
  11. package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -1
  12. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  13. package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -1
  14. package/dist/__tests__/unit/frontmatter-sync.test.js.map +1 -1
  15. package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -1
  16. package/dist/__tests__/unit/inert-config-keys-sync.test.js.map +1 -1
  17. package/dist/__tests__/unit/init.test.js.map +1 -1
  18. package/dist/__tests__/unit/install-banner-gating.test.js.map +1 -1
  19. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts +22 -0
  20. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts.map +1 -0
  21. package/dist/__tests__/unit/jwt-auth-config-drift.test.js +193 -0
  22. package/dist/__tests__/unit/jwt-auth-config-drift.test.js.map +1 -0
  23. package/dist/__tests__/unit/openapi-schema-patch-sync.test.js.map +1 -1
  24. package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js +1 -1
  25. package/dist/__tests__/unit/openapi.test.js.map +1 -1
  26. package/dist/__tests__/unit/package-config.test.js.map +1 -1
  27. package/dist/__tests__/unit/project-favicon.test.js.map +1 -1
  28. package/dist/__tests__/unit/prompt-vendoring.test.js.map +1 -1
  29. package/dist/__tests__/unit/risky-expression-scanner-sync.test.js.map +1 -1
  30. package/dist/__tests__/unit/seo-ai-schema.test.js.map +1 -1
  31. package/dist/__tests__/unit/snippet-scanner-sync.test.js.map +1 -1
  32. package/dist/__tests__/unit/spec-generation-notice.test.js.map +1 -1
  33. package/dist/__tests__/unit/templates-consistency.test.js.map +1 -1
  34. package/dist/__tests__/unit/validate-inert-config-keys.test.js.map +1 -1
  35. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts +2 -0
  36. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts.map +1 -0
  37. package/dist/__tests__/unit/validate-jwt-auth.test.js +77 -0
  38. package/dist/__tests__/unit/validate-jwt-auth.test.js.map +1 -0
  39. package/dist/__tests__/unit/validate-password-warning.test.js.map +1 -1
  40. package/dist/__tests__/unit/vendored-sync.test.js +11 -0
  41. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  42. package/dist/commands/broken-links.js.map +1 -1
  43. package/dist/commands/clean.js.map +1 -1
  44. package/dist/commands/deploy/cloudflare.js.map +1 -1
  45. package/dist/commands/deploy.js +16 -0
  46. package/dist/commands/deploy.js.map +1 -1
  47. package/dist/commands/dev.js.map +1 -1
  48. package/dist/commands/fix.d.ts.map +1 -1
  49. package/dist/commands/fix.js.map +1 -1
  50. package/dist/commands/init.js.map +1 -1
  51. package/dist/commands/update.js.map +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/lib/auth.js.map +1 -1
  54. package/dist/lib/deps.d.ts +4 -4
  55. package/dist/lib/deps.d.ts.map +1 -1
  56. package/dist/lib/deps.js +15 -10
  57. package/dist/lib/deps.js.map +1 -1
  58. package/dist/lib/docs-config.d.ts +1 -0
  59. package/dist/lib/docs-config.d.ts.map +1 -1
  60. package/dist/lib/docs-config.js +49 -0
  61. package/dist/lib/docs-config.js.map +1 -1
  62. package/dist/lib/errors.d.ts.map +1 -1
  63. package/dist/lib/errors.js.map +1 -1
  64. package/dist/lib/find-project-dir.js.map +1 -1
  65. package/dist/lib/first-run-banner.js.map +1 -1
  66. package/dist/lib/mdx-validator.js.map +1 -1
  67. package/dist/lib/missing-description-scan.js.map +1 -1
  68. package/dist/utils/update-checker.js.map +1 -1
  69. package/package.json +9 -8
  70. package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +1 -7
  71. package/vendored/app/(jd-system)/jd-inactive/page.tsx +2 -4
  72. package/vendored/app/(unlock)/jd/auth/callback/CallbackClient.tsx +33 -0
  73. package/vendored/app/(unlock)/jd/auth/callback/page.tsx +15 -0
  74. package/vendored/app/(unlock)/jd/unlock/page.tsx +29 -7
  75. package/vendored/app/api/jd/auth/callback/route.ts +103 -0
  76. package/vendored/app/api/jd/auth/logout/route.ts +38 -0
  77. package/vendored/app/api/jd/auth/me/route.ts +52 -0
  78. package/vendored/app/api/jd/unlock/route.ts +26 -2
  79. package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +65 -22
  80. package/vendored/app/api/raw-content/[...slug]/route.ts +21 -1
  81. package/vendored/app/layout.tsx +7 -0
  82. package/vendored/components/AIActionsMenu.tsx +19 -2
  83. package/vendored/components/mdx/OpenApiEndpoint.tsx +4 -0
  84. package/vendored/components/navigation/Header.tsx +5 -0
  85. package/vendored/components/navigation/LogoutButton.tsx +41 -0
  86. package/vendored/lib/auth-resolver.ts +125 -50
  87. package/vendored/lib/cached-redis.ts +28 -3
  88. package/vendored/lib/docs-types.ts +17 -1
  89. package/vendored/lib/glob-match.ts +54 -0
  90. package/vendored/lib/jwt-auth-config.ts +37 -0
  91. package/vendored/lib/jwt-session.ts +98 -0
  92. package/vendored/lib/language-utils.ts +14 -2
  93. package/vendored/lib/layout-helpers.tsx +4 -0
  94. package/vendored/lib/mdx-inline-components.ts +1 -1
  95. package/vendored/lib/middleware-helpers.ts +27 -6
  96. package/vendored/lib/nav-group-filter.ts +81 -0
  97. package/vendored/lib/public-paths-resolver.ts +164 -7
  98. package/vendored/lib/r2-public-storage-guard.ts +26 -4
  99. package/vendored/lib/redis.ts +20 -2
  100. package/vendored/lib/render-doc-page.tsx +76 -34
  101. package/vendored/lib/root-page-slug.ts +110 -0
  102. package/vendored/lib/same-origin.ts +38 -0
  103. package/vendored/lib/sanitize-from.ts +31 -1
  104. package/vendored/lib/session-hint.ts +11 -0
  105. package/vendored/lib/snippet-compiler-isr.ts +1 -1
  106. package/vendored/lib/snippet-loader-isr.ts +1 -1
  107. package/vendored/lib/validate-config.ts +14 -0
  108. package/vendored/lib/viewer-navigation.ts +45 -0
  109. package/vendored/lib/visibility.ts +65 -0
  110. package/vendored/schema/docs-schema.json +25 -0
  111. package/vendored/shared/auth-cookie.ts +98 -5
  112. package/vendored/workspace-package-lock.json +474 -183
@@ -5,8 +5,8 @@ import {BrandedInactive} from "./BrandedInactive";
5
5
 
6
6
  // Read project data from middleware-set headers per-request. We can't
7
7
  // use force-static because the proxy sets different headers for each
8
- // paused project (name, logo, custom-domain flag). The response is
9
- // still trivially cacheable at the edge with a short TTL.
8
+ // paused project (name, logo). The response is still trivially
9
+ // cacheable at the edge with a short TTL.
10
10
  export const dynamic = "force-dynamic";
11
11
 
12
12
  export default async function InactivePage() {
@@ -29,12 +29,10 @@ export default async function InactivePage() {
29
29
  // inactive-html.ts via sanitizeLogoUrl.
30
30
  const rawLogo = h.get("x-jd-project-logo");
31
31
  const logoUrl = rawLogo && isUrlSafe(rawLogo) ? rawLogo : undefined;
32
- const hasCustomDomain = h.get("x-jd-custom-domain") === "1";
33
32
  return (
34
33
  <BrandedInactive
35
34
  projectName={projectName}
36
35
  logoUrl={logoUrl}
37
- hasCustomDomain={hasCustomDomain}
38
36
  />
39
37
  );
40
38
  }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ /**
6
+ * The JWT arrives in the URL fragment (never sent to the server / logs).
7
+ * Exchange it for a session cookie via the POST endpoint, then continue to
8
+ * the original destination.
9
+ */
10
+ export default function CallbackClient() {
11
+ const [error, setError] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ const jwt = window.location.hash ? window.location.hash.slice(1) : '';
15
+ const redirect = new URLSearchParams(window.location.search).get('redirect') || '/';
16
+ if (!jwt) { setError('Missing token — please sign in again.'); return; }
17
+ // History hygiene: drop the fragment so the token can't be copied from the URL bar.
18
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
19
+ fetch('/_jd/api/auth/callback', {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify({ jwt, redirect }),
23
+ })
24
+ .then(async (r) => {
25
+ const data = (await r.json()) as { ok?: boolean; redirect?: string };
26
+ if (r.ok && data.ok) window.location.replace(data.redirect || '/');
27
+ else setError('Sign-in failed — please try again from your login page.');
28
+ })
29
+ .catch(() => setError('Sign-in failed — please try again from your login page.'));
30
+ }, []);
31
+
32
+ return <p role="status">{error ?? 'Signing you in…'}</p>;
33
+ }
@@ -0,0 +1,15 @@
1
+ import CallbackClient from './CallbackClient';
2
+
3
+ export const dynamic = 'force-dynamic';
4
+
5
+ export const metadata = {
6
+ robots: { index: false, follow: false },
7
+ };
8
+
9
+ export default function JwtCallbackPage() {
10
+ return (
11
+ <div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center' }}>
12
+ <CallbackClient />
13
+ </div>
14
+ );
15
+ }
@@ -22,7 +22,11 @@ export default async function UnlockPage({ searchParams }: UnlockPageProps) {
22
22
 
23
23
  const config = slug ? await getDocsConfig(slug).catch(() => null) : null;
24
24
  const siteName = config?.name || 'Documentation';
25
- const hint = config?.auth?.password?.hint || null;
25
+ // The gate rewrites jwt tenants here only when no usable loginUrl exists
26
+ // (missing, non-https, or one that would loop back into the gate). There
27
+ // is no password to type, so render an explainer instead of a dead-end form.
28
+ const jwtMode = config?.auth?.jwt?.enabled === true;
29
+ const hint = jwtMode ? null : config?.auth?.password?.hint || null;
26
30
  const primaryColor = config?.colors?.primary || '#635BFF';
27
31
  const logo = normalizeLogo(config?.logo, config?.assetVersion);
28
32
 
@@ -133,15 +137,33 @@ export default async function UnlockPage({ searchParams }: UnlockPageProps) {
133
137
  margin: 0,
134
138
  }}
135
139
  >
136
- This site is private. Enter the password to continue.
140
+ {jwtMode
141
+ ? 'This site is private. Sign in through your organization to continue.'
142
+ : 'This site is private. Enter the password to continue.'}
137
143
  </p>
138
144
  </header>
139
145
 
140
- <UnlockForm
141
- from={from}
142
- showError={showError}
143
- primaryColor={primaryColor}
144
- />
146
+ {jwtMode ? (
147
+ <p
148
+ style={{
149
+ fontSize: 14,
150
+ lineHeight: 1.5,
151
+ color: '#6b7280',
152
+ textAlign: 'center',
153
+ margin: 0,
154
+ }}
155
+ >
156
+ Access is granted by your organization&apos;s sign-in. If you arrived
157
+ here from a shared link, open the documentation from the application
158
+ that hosts it.
159
+ </p>
160
+ ) : (
161
+ <UnlockForm
162
+ from={from}
163
+ showError={showError}
164
+ primaryColor={primaryColor}
165
+ />
166
+ )}
145
167
 
146
168
  {hint && (
147
169
  <p
@@ -0,0 +1,103 @@
1
+ /**
2
+ * POST /api/jd/auth/callback — JWT token-exchange endpoint.
3
+ *
4
+ * Node runtime only — jose EdDSA verification runs here. The client-side
5
+ * callback page (app/(unlock)/jd/auth/callback) extracts the JWT from the
6
+ * URL fragment (never sent to any server) and POSTs it here as a JSON body.
7
+ * On success, mints a v2 session cookie carrying the verified groups (and,
8
+ * budget permitting, playground inputs) and hands back the sanitized
9
+ * post-login redirect.
10
+ *
11
+ * Error ladder:
12
+ * 403 cross-site — the POST did not come from this origin (login-CSRF:
13
+ * a cross-site POST would store an attacker-chosen session, groups and
14
+ * playground inputs included, in the victim's cookie jar).
15
+ * 404 unknown-project / not-enabled — no slug header, or project isn't in
16
+ * jwt mode (mirrors resolveAuth's null-on-off-or-wrong-mode contract).
17
+ * 400 bad-request — malformed JSON or missing/oversized jwt field.
18
+ * 401 <reason> — verifyDocsJwt rejected the token (invalid signature,
19
+ * expired, host mismatch, etc.) — reason echoed from jwt-session.
20
+ * 503 unavailable — EDGE_AUTH_SECRET unset (never mint an unsigned
21
+ * cookie) OR resolveAuth threw (Redis outage, fail closed).
22
+ * 401 session-too-large — verified, but even the groups-only cookie
23
+ * exceeds the 4KB browser budget; refuse rather than set a cookie
24
+ * that will be silently truncated or dropped.
25
+ */
26
+
27
+ import { NextResponse } from 'next/server';
28
+ import { resolveAuth, AuthResolutionError } from '@/lib/auth-resolver';
29
+ import { verifyDocsJwt } from '@/lib/jwt-session';
30
+ import { signAuthCookieV2 } from '@/shared/auth-cookie';
31
+ import { sanitizeCallbackRedirect } from '@/lib/sanitize-from';
32
+ import { isSameOriginRequest } from '@/lib/same-origin';
33
+
34
+ export const runtime = 'nodejs';
35
+ export const dynamic = 'force-dynamic';
36
+
37
+ function getRequestHost(req: Request): string {
38
+ // Same rule as the unlock route: customer reverse proxies forward the real
39
+ // public host in x-jamdesk-forwarded-host.
40
+ return req.headers.get('x-jamdesk-forwarded-host') || req.headers.get('host') || '';
41
+ }
42
+
43
+ export async function POST(req: Request): Promise<NextResponse> {
44
+ if (!isSameOriginRequest(req)) {
45
+ return NextResponse.json({ ok: false, error: 'cross-site' }, { status: 403 });
46
+ }
47
+ const slug = req.headers.get('x-project-slug');
48
+ const secret = process.env.EDGE_AUTH_SECRET;
49
+ if (!slug) return NextResponse.json({ ok: false, error: 'unknown-project' }, { status: 404 });
50
+ if (!secret) return NextResponse.json({ ok: false, error: 'unavailable' }, { status: 503 });
51
+
52
+ let body: { jwt?: unknown; redirect?: unknown };
53
+ try { body = await req.json(); } catch { return NextResponse.json({ ok: false, error: 'bad-request' }, { status: 400 }); }
54
+ if (typeof body.jwt !== 'string' || body.jwt.length === 0 || body.jwt.length > 8192) {
55
+ return NextResponse.json({ ok: false, error: 'bad-request' }, { status: 400 });
56
+ }
57
+
58
+ let auth;
59
+ try { auth = await resolveAuth(slug); } catch (e) {
60
+ if (e instanceof AuthResolutionError) return NextResponse.json({ ok: false, error: 'unavailable' }, { status: 503 });
61
+ throw e;
62
+ }
63
+ if (!auth || auth.authType !== 'jwt' || !auth.publicKeyPem) {
64
+ return NextResponse.json({ ok: false, error: 'not-enabled' }, { status: 404 });
65
+ }
66
+
67
+ const host = getRequestHost(req);
68
+ const outcome = await verifyDocsJwt(body.jwt, auth.publicKeyPem, host);
69
+ if (!outcome.ok) {
70
+ return NextResponse.json({ ok: false, error: outcome.reason }, { status: 401 });
71
+ }
72
+
73
+ const expiresAt = outcome.user.sessionExpiresAt;
74
+ // Cookie budget: browsers cap a cookie at ~4KB and drop/truncate silently
75
+ // beyond it. Try with playground inputs; if the signed value is too big,
76
+ // retry without them (session still granted); if groups alone blow the
77
+ // budget, refuse loudly instead of setting a cookie that won't stick.
78
+ const MAX_COOKIE_VALUE_LEN = 3800;
79
+ let cookieValue = await signAuthCookieV2(
80
+ { slug, host, version: auth.version, expiresAt },
81
+ { g: outcome.user.groups, ...(outcome.user.apiPlaygroundInputs ? { api: outcome.user.apiPlaygroundInputs } : {}) },
82
+ secret,
83
+ );
84
+ if (cookieValue.length > MAX_COOKIE_VALUE_LEN && outcome.user.apiPlaygroundInputs) {
85
+ cookieValue = await signAuthCookieV2(
86
+ { slug, host, version: auth.version, expiresAt },
87
+ { g: outcome.user.groups },
88
+ secret,
89
+ );
90
+ }
91
+ if (cookieValue.length > MAX_COOKIE_VALUE_LEN) {
92
+ return NextResponse.json({ ok: false, error: 'session-too-large' }, { status: 401 });
93
+ }
94
+ const maxAge = Math.max(1, Math.floor((expiresAt - Date.now()) / 1000));
95
+ const res = NextResponse.json({ ok: true, redirect: sanitizeCallbackRedirect(body.redirect) });
96
+ res.headers.set('Set-Cookie', `jd_auth_${slug}=${cookieValue}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`);
97
+ // Non-HttpOnly hint (no security value, carries nothing): lets the client
98
+ // header skip the /me round-trip on anonymous page views — cookies are
99
+ // host-scoped, so a fixed name needs no slug.
100
+ res.headers.append('Set-Cookie', `jd_authed=1; Path=/; Secure; SameSite=Lax; Max-Age=${maxAge}`);
101
+ res.headers.set('Cache-Control', 'no-store');
102
+ return res;
103
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * GET /api/jd/auth/logout — clears the JWT session cookie and redirects.
3
+ *
4
+ * Node runtime not strictly required (no crypto here), but kept consistent
5
+ * with the other jd/auth routes. Always 303s — logout must never fail loudly,
6
+ * even when the project isn't JWT-gated or resolveAuth throws (Redis outage).
7
+ *
8
+ * Redirect target: the customer's `loginUrl` when known (so the user lands
9
+ * back on their own login page), else relative '/' — same rule as the unlock
10
+ * route's redirects: never `NextResponse.redirect(new URL('/', req.url))`,
11
+ * since `req.url`'s host is the internal rewrite host, not the customer's
12
+ * public host behind a reverse proxy.
13
+ */
14
+
15
+ import { resolveAuth } from '@/lib/auth-resolver';
16
+
17
+ export const runtime = 'nodejs';
18
+ export const dynamic = 'force-dynamic';
19
+
20
+ export async function GET(req: Request): Promise<Response> {
21
+ const slug = req.headers.get('x-project-slug');
22
+ // Relative '/' works for the same-site fallback; the customer loginUrl is
23
+ // absolute by validation.
24
+ let location = '/';
25
+ if (slug) {
26
+ try {
27
+ const auth = await resolveAuth(slug);
28
+ if (auth?.authType === 'jwt' && auth.loginUrl) location = auth.loginUrl;
29
+ } catch { /* logout must always succeed; fall back to '/' */ }
30
+ }
31
+ const headers = new Headers({ Location: location, 'Cache-Control': 'no-store' });
32
+ if (slug) {
33
+ headers.append('Set-Cookie', `jd_auth_${slug}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
34
+ }
35
+ // Always clear the client-readable hint the callback set (Task 8).
36
+ headers.append('Set-Cookie', 'jd_authed=; Path=/; Secure; SameSite=Lax; Max-Age=0');
37
+ return new Response(null, { status: 303, headers });
38
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * GET /api/jd/auth/me — session info for the docs header and API playground.
3
+ *
4
+ * Node runtime — verifyAuthCookie's HMAC check runs here (Web Crypto, but
5
+ * kept on the Node route family alongside logout/callback for consistency).
6
+ * Degrades to `{authenticated: false}` on ANY failure (missing slug/secret,
7
+ * resolveAuth throwing, no cookie, invalid/expired cookie, wrong format
8
+ * version) — this is a read-only info endpoint, not a gate, so fail-closed
9
+ * here just means "logged out," never a 5xx. Always `Cache-Control: no-store`
10
+ * since the response is per-session and must never be shared across users
11
+ * or slugs by an intermediate cache.
12
+ *
13
+ * `x-project-slug` is set by the internal gate/rewrite from a resolved,
14
+ * validated project slug and stripped from any inbound client value
15
+ * (middleware-helpers.ts UNTRUSTED_HEADERS) — safe to interpolate into the
16
+ * cookie-name RegExp below without further escaping.
17
+ */
18
+
19
+ import { NextResponse } from 'next/server';
20
+ import { resolveAuth } from '@/lib/auth-resolver';
21
+ import { verifyAuthCookie } from '@/shared/auth-cookie';
22
+
23
+ export const runtime = 'nodejs';
24
+ export const dynamic = 'force-dynamic';
25
+
26
+ const UNAUTHENTICATED = { authenticated: false } as const;
27
+
28
+ export async function GET(req: Request): Promise<NextResponse> {
29
+ const noStore = { headers: { 'Cache-Control': 'no-store' } };
30
+ const slug = req.headers.get('x-project-slug');
31
+ const secret = process.env.EDGE_AUTH_SECRET;
32
+ if (!slug || !secret) return NextResponse.json(UNAUTHENTICATED, noStore);
33
+
34
+ let auth;
35
+ try { auth = await resolveAuth(slug); } catch { return NextResponse.json(UNAUTHENTICATED, noStore); }
36
+ if (!auth || auth.authType !== 'jwt') return NextResponse.json(UNAUTHENTICATED, noStore);
37
+
38
+ const cookieHeader = req.headers.get('cookie') || '';
39
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)jd_auth_${slug}=([^;]+)`));
40
+ const host = req.headers.get('x-jamdesk-forwarded-host') || req.headers.get('host') || '';
41
+ if (!match) return NextResponse.json(UNAUTHENTICATED, noStore);
42
+
43
+ const v = await verifyAuthCookie(match[1], { slug, host, version: auth.version }, secret);
44
+ if (!v.valid || v.formatVersion !== 'v2') return NextResponse.json(UNAUTHENTICATED, noStore);
45
+
46
+ return NextResponse.json({
47
+ authenticated: true,
48
+ authType: 'jwt',
49
+ groups: v.extras?.g ?? [],
50
+ ...(v.extras?.api ? { apiPlaygroundInputs: v.extras.api } : {}),
51
+ }, noStore);
52
+ }
@@ -19,11 +19,12 @@ export const dynamic = 'force-dynamic';
19
19
  import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
20
20
  import { promisify } from 'util';
21
21
  import { redis } from '@/lib/redis';
22
- import { getProjectAuthSecret } from '@/lib/cached-redis';
22
+ import { getProjectAuthSecret, getProjectAuthPublic } from '@/lib/cached-redis';
23
23
  import { sanitizeFrom } from '@/lib/sanitize-from';
24
24
  import { VALID_SLUG_RE } from '@/lib/middleware-helpers';
25
25
  import { signAuthCookie } from '@/shared/auth-cookie';
26
26
  import { secretsEqual } from '@/lib/crypto-helpers';
27
+ import { isSameOriginRequest } from '@/lib/same-origin';
27
28
 
28
29
  const scryptAsync = promisify(scrypt) as (
29
30
  password: string,
@@ -230,6 +231,13 @@ async function fetchStoredSecret(slug: string): Promise<StoredSecret | null> {
230
231
  }
231
232
 
232
233
  export async function POST(req: Request): Promise<Response> {
234
+ // Login-CSRF guard: a cross-site form POST would store a session in the
235
+ // victim's jar. Checked first — before scrypt, before rate limiting — so a
236
+ // cross-site attacker cannot even use the endpoint as a scrypt oracle.
237
+ if (!isSameOriginRequest(req)) {
238
+ return new Response('Forbidden: cross-site request', { status: 403 });
239
+ }
240
+
233
241
  // Fail-closed: if the signing secret is missing, we cannot issue valid
234
242
  // cookies. Return 503 rather than silently issuing unsigned tokens.
235
243
  const secret = process.env.EDGE_AUTH_SECRET;
@@ -293,8 +301,14 @@ export async function POST(req: Request): Promise<Response> {
293
301
  }
294
302
 
295
303
  let stored: StoredSecret | null;
304
+ let authType: 'password' | 'jwt' | undefined;
296
305
  try {
297
- stored = await fetchStoredSecret(slug);
306
+ const [storedResult, pubResult] = await Promise.all([
307
+ fetchStoredSecret(slug),
308
+ getProjectAuthPublic(slug),
309
+ ]);
310
+ stored = storedResult;
311
+ authType = pubResult?.authType;
298
312
  } catch (err) {
299
313
  console.error('[unlock] Redis fetch failed — failing closed', {
300
314
  slug,
@@ -305,6 +319,16 @@ export async function POST(req: Request): Promise<Response> {
305
319
  headers: { 'Retry-After': '30' },
306
320
  });
307
321
  }
322
+
323
+ // JWT-mode projects don't use this route. During a password→jwt migration
324
+ // coexistence window, a lingering password hash would otherwise let the
325
+ // unlock form verify and mint a v1 cookie the jwt gate rejects — not a
326
+ // bypass (the jwt gate never accepts a v1 cookie), but a confusing dead
327
+ // end for the user. Fail fast instead.
328
+ if (authType === 'jwt') {
329
+ return new Response('not-enabled', { status: 404 });
330
+ }
331
+
308
332
  const hashToVerify = stored?.hash ?? DUMMY_HASH;
309
333
 
310
334
  // Always run scrypt — even with DUMMY_HASH when no password is set — to
@@ -20,6 +20,7 @@ import { apiSpecsMarkdownFooter } from '@/lib/api-specs-markdown-hint';
20
20
  import { collectLocalSpecPaths } from '@/lib/openapi/operation-description';
21
21
  import { operationMarkdownForRef } from '@/lib/openapi/enrich-page-infos';
22
22
  import { injectAgentDirective } from '@/lib/agent-directive';
23
+ import { resolveMarkdownFallbackPath } from '@/lib/root-page-slug';
23
24
  import { logger } from '@/shared/logger';
24
25
  import type { DocsConfig } from '@/lib/docs-types';
25
26
  import type { OpenAPI } from 'openapi-types';
@@ -66,38 +67,65 @@ export async function GET(
66
67
  return new NextResponse('Not found', { status: 404 });
67
68
  }
68
69
 
69
- const pagePath = slug.join('/');
70
+ const requestedPath = slug.join('/');
70
71
 
72
+ // One config fetch feeds the root-alias fallback below, the directive
73
+ // (hostAtDocs link prefix) and the api-specs hint. It is STARTED before the
74
+ // content fetch so the two R2 reads overlap (the HTML path does the same
75
+ // with its configP) and awaited only where needed. A failed fetch must
76
+ // never 500 an export: the fallback is skipped, the directive falls back to
77
+ // the subdomain-rooted /llms.txt and the hint keeps its own defaults.
78
+ //
79
+ // The async IIFE (not `fetchDocsConfig(project).catch(...)`) is load-bearing
80
+ // in THREE ways, all of which a bare `.catch()` gets wrong:
81
+ // 1. a synchronous throw from fetchDocsConfig is caught, not propagated;
82
+ // 2. a non-promise return is normalised — several existing suites mock
83
+ // `fetchDocsConfig: vi.fn()` with no implementation, so it returns
84
+ // `undefined` and `.catch` on it is a TypeError that 500s the route;
85
+ // 3. `?? null` keeps the type `DocsConfig | null`, which is what
86
+ // withApiSpecsHint's parameter and the `config?.` reads below expect.
87
+ const configP: Promise<DocsConfig | null> = (async () => {
88
+ try {
89
+ return (await fetchDocsConfig(project)) ?? null;
90
+ } catch (err) {
91
+ logger.warn('[markdown-export] config fetch failed', {
92
+ project,
93
+ error: err instanceof Error ? err.message : String(err),
94
+ });
95
+ return null;
96
+ }
97
+ })();
98
+
99
+ // `/`, `/index.md` and `/<lang>` reach this route as the `index` sentinel or
100
+ // a bare language code (proxy.ts markdown branch). The HTML for those URLs
101
+ // renders the first nav page in place (render-doc-page's resolveSlug); the
102
+ // export does the same — but only after the literal path missed, so a
103
+ // project with a real index.mdx keeps serving it and a missing real page
104
+ // still 404s. Any other R2 failure is a 502 straight away: falling back
105
+ // during an outage would report it as a 404 and double the load.
71
106
  let raw: string;
72
107
  try {
73
- raw = await fetchMdxContent(project, pagePath);
108
+ raw = await fetchMdxContent(project, requestedPath);
74
109
  } catch (err) {
75
- const msg = err instanceof Error ? err.message : '';
76
- const name = err instanceof Error ? err.name : '';
77
- if (
78
- msg.startsWith(R2_NOT_FOUND_MESSAGE_PREFIX) ||
79
- R2_NOT_FOUND_ERROR_NAMES.has(name)
80
- ) {
110
+ if (!isMissingPageError(err)) {
111
+ return new NextResponse('Upstream unavailable', { status: 502 });
112
+ }
113
+ const fallbackPath = resolveMarkdownFallbackPath(requestedPath, await configP);
114
+ if (!fallbackPath) {
81
115
  return new NextResponse('Not found', { status: 404 });
82
116
  }
83
- return new NextResponse('Upstream unavailable', { status: 502 });
117
+ try {
118
+ raw = await fetchMdxContent(project, fallbackPath);
119
+ } catch (fallbackErr) {
120
+ return isMissingPageError(fallbackErr)
121
+ ? new NextResponse('Not found', { status: 404 })
122
+ : new NextResponse('Upstream unavailable', { status: 502 });
123
+ }
84
124
  }
85
125
 
126
+ const config = await configP;
86
127
  const filtered = filterVisibility(raw, 'agents');
87
128
 
88
- // One config fetch feeds both the directive (hostAtDocs link prefix) and the
89
- // api-specs hint. A failed fetch must never 500 an export: directive falls
90
- // back to the subdomain-rooted /llms.txt and the hint keeps its own defaults.
91
- let config: DocsConfig | null = null;
92
- try {
93
- config = await fetchDocsConfig(project);
94
- } catch (err) {
95
- logger.warn('[markdown-export] config fetch failed', {
96
- project,
97
- error: err instanceof Error ? err.message : String(err),
98
- });
99
- }
100
-
101
129
  // hostAtDocs rides the middleware-stamped header — the stored docs.json
102
130
  // never carries it (runtime-only field, see DocsConfig), so reading it off
103
131
  // `config` alone always resolved false in prod. The config check stays as a
@@ -149,6 +177,21 @@ export async function GET(
149
177
  });
150
178
  }
151
179
 
180
+ /**
181
+ * The two shapes fetchMdxContent throws for a missing page — the
182
+ * `Page not found: …` message the S3 path builds and the error names the
183
+ * public-fetch path forwards. Deliberately NOT r2-content's exported
184
+ * `isR2NotFound`: that one classifies raw S3ServiceException / HTTP 404
185
+ * failures inside the fetchers and does not recognise the message shape
186
+ * that reaches this route. This is the same test the route ran inline
187
+ * before the fallback existed, now needed at two sites.
188
+ */
189
+ function isMissingPageError(err: unknown): boolean {
190
+ const msg = err instanceof Error ? err.message : '';
191
+ const name = err instanceof Error ? err.name : '';
192
+ return msg.startsWith(R2_NOT_FOUND_MESSAGE_PREFIX) || R2_NOT_FOUND_ERROR_NAMES.has(name);
193
+ }
194
+
152
195
  /**
153
196
  * Append the agent-facing "download all OpenAPI specs" footer when the page is
154
197
  * an API/OpenAPI page of a spec-bearing site whose AI-actions menu is enabled,
@@ -5,6 +5,7 @@ import { parseFrontmatterLenient } from '@/lib/frontmatter-utils';
5
5
  import { collectLocalSpecPaths } from '@/lib/openapi/operation-description';
6
6
  import { operationMarkdownForRef } from '@/lib/openapi/enrich-page-infos';
7
7
  import { getCachedSpec } from '@/lib/openapi/cache';
8
+ import { resolveMarkdownFallbackPath } from '@/lib/root-page-slug';
8
9
  import { NextResponse } from 'next/server';
9
10
 
10
11
  /**
@@ -19,7 +20,7 @@ export async function GET(
19
20
  const { slug } = await params;
20
21
  try {
21
22
  const loader = getContentLoader();
22
- const raw = await loader.getContent(slug.join('/'));
23
+ const raw = await getContentWithRootFallback(loader, slug.join('/'));
23
24
  const filtered = filterVisibility(raw, 'agents');
24
25
  return new NextResponse(await withOperationMarkdown(filtered, raw, loader), {
25
26
  headers: {
@@ -33,6 +34,25 @@ export async function GET(
33
34
  }
34
35
  }
35
36
 
37
+ /**
38
+ * Same root-alias rule as the production export: `/` and `/index.md` arrive
39
+ * as `index`, `/fr` as `fr`; when that file is missing, serve the first nav
40
+ * page — what the HTML for the same URL renders. Rethrows the original miss
41
+ * when there is no fallback so the caller's catch still answers 404.
42
+ */
43
+ async function getContentWithRootFallback(
44
+ loader: ReturnType<typeof getContentLoader>,
45
+ requestedPath: string,
46
+ ): Promise<string> {
47
+ try {
48
+ return await loader.getContent(requestedPath);
49
+ } catch (err) {
50
+ const fallbackPath = resolveMarkdownFallbackPath(requestedPath, await loader.getConfig());
51
+ if (!fallbackPath) throw err;
52
+ return loader.getContent(fallbackPath);
53
+ }
54
+ }
55
+
36
56
  /**
37
57
  * Append the operation markdown for an `openapi:`-backed page, exactly as the
38
58
  * production route does.
@@ -23,6 +23,7 @@ import type { DocsConfig } from '@/lib/docs-types';
23
23
  import { buildSiteTitle, getFaviconIcons, FALLBACK_METADATA } from '@/lib/seo';
24
24
  import { withR2OpsContext, emitR2OpsSummary } from '@/lib/r2-content';
25
25
  import { DocsChrome, getLocalFileContent, loadCustomAssets } from '@/lib/layout-helpers';
26
+ import { filterNavigationForViewer } from '@/lib/viewer-navigation';
26
27
 
27
28
  async function generateMetadataImpl(): Promise<Metadata> {
28
29
  // The placeholder shell would otherwise render with the paused
@@ -150,6 +151,12 @@ async function rootLayoutImpl({
150
151
  try {
151
152
  config = await getIsrDocsConfig(projectSlug);
152
153
  config = { ...config, hostAtDocs, docsPrefix };
154
+ if (config.auth?.jwt?.enabled) {
155
+ config = {
156
+ ...config,
157
+ navigation: await filterNavigationForViewer(projectSlug, config.navigation, headersList),
158
+ };
159
+ }
153
160
  } catch {
154
161
  config = {
155
162
  name: 'Documentation',
@@ -289,8 +289,25 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
289
289
 
290
290
  // Compute URLs client-side to survive SPA navigation
291
291
  const origin = typeof window !== 'undefined' ? window.location.origin : '';
292
- const mdRelativePath = `${pathname}.md`;
293
- const mdAbsoluteUrl = `${origin}${pathname}.md`;
292
+ // At the docs ROOT, `${pathname}.md` is never a working URL, and it fails
293
+ // three different ways depending on how the site is mounted:
294
+ // - bare root: pathname `/` -> `/.md`, which classifyScannerProbe reads as
295
+ // a dotfile and proxy.ts 404s ~770 lines before the markdown branch (and
296
+ // which the CLI's `/:path*.md` dev rewrite never matches either);
297
+ // - worker-fronted subpath (jamdesk.com/docs): `/docs.md` isn't proxied at
298
+ // all — cloudflare-worker/index.js's shouldProxy requires `=== '/docs'`
299
+ // or `startsWith('/docs/')` — so it lands on marketing's 404;
300
+ // - custom subpath (`/fun`): `/fun.md` reaches us but getDocsPrefixRouting
301
+ // drops it on the dotted-asset guard before internalising, so the route
302
+ // sees the slug `fun` and 404s.
303
+ // `<linkPrefix>/index.md` is the one shape that works on all three: it is the
304
+ // sentinel both markdown routes resolve to the first nav page, and it always
305
+ // sits under the prefix the worker and the proxy actually route. linkPrefix
306
+ // is '' on a bare-root tenant, so that case stays `/index.md`.
307
+ const atDocsRoot = pathname === linkPrefix || pathname === '/' || pathname === '';
308
+ const mdPath = atDocsRoot ? `${linkPrefix}/index.md` : `${pathname}.md`;
309
+ const mdRelativePath = mdPath;
310
+ const mdAbsoluteUrl = `${origin}${mdPath}`;
294
311
  const mcpUrl = `${origin}${linkPrefix}/_mcp`;
295
312
  const apiSpecZipUrl = buildApiSpecZipUrl(origin, linkPrefix);
296
313
 
@@ -68,6 +68,9 @@ interface OpenApiEndpointProps {
68
68
  * are the same text — an author's DIFFERENT frontmatter description must
69
69
  * not suppress the operation's own. */
70
70
  descriptionAlreadyShown?: boolean;
71
+ /** When true, PlaygroundModal fetches the signed-in JWT session's
72
+ * apiPlaygroundInputs on open and pre-fills matching values. */
73
+ jwtAuthEnabled?: boolean;
71
74
  }
72
75
 
73
76
  /**
@@ -830,6 +833,7 @@ export function OpenApiEndpoint({
830
833
  languages,
831
834
  playgroundOnly,
832
835
  descriptionAlreadyShown,
836
+ jwtAuthEnabled,
833
837
  }: OpenApiEndpointProps) {
834
838
  const {
835
839
  method,
@@ -13,6 +13,7 @@ import { ThemeToggle } from '@/components/theme/ThemeToggle';
13
13
  import { LazySearchModal as SearchModal } from '@/components/search/LazySearchModal';
14
14
  import { DefaultLogo } from './DefaultLogo';
15
15
  import { LanguageSelector } from './LanguageSelector';
16
+ import LogoutButton from './LogoutButton';
16
17
  import { resolveNavigation } from '@/lib/navigation-resolver';
17
18
  import { getIconClass } from '@/lib/icon-utils';
18
19
  import { useLinkPrefix } from '@/lib/link-prefix-context';
@@ -550,6 +551,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
550
551
  </>
551
552
  )}
552
553
 
554
+ {/* Log-out affordance - only mounted for JWT-authenticated docs sites;
555
+ renders nothing until the client confirms an active session. */}
556
+ {config.auth?.jwt?.enabled === true && <LogoutButton />}
557
+
553
558
  {/* Theme toggle - hidden on mobile since it's in the sidebar menu.
554
559
  Also hidden when appearance.strict pins users to the configured mode. */}
555
560
  {!config.appearance?.strict && (