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.
- package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -1
- package/dist/__tests__/integration/init.integration.test.js.map +1 -1
- package/dist/__tests__/integration/prepublish.integration.test.js.map +1 -1
- package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
- package/dist/__tests__/unit/banner-art-sync.test.js.map +1 -1
- package/dist/__tests__/unit/deploy-templates.test.js.map +1 -1
- package/dist/__tests__/unit/deploy.test.js +106 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -1
- package/dist/__tests__/unit/deprecated-components-sync.test.js.map +1 -1
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -1
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -1
- package/dist/__tests__/unit/frontmatter-sync.test.js.map +1 -1
- package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -1
- package/dist/__tests__/unit/inert-config-keys-sync.test.js.map +1 -1
- package/dist/__tests__/unit/init.test.js.map +1 -1
- package/dist/__tests__/unit/install-banner-gating.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/openapi-schema-patch-sync.test.js.map +1 -1
- package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js +1 -1
- package/dist/__tests__/unit/openapi.test.js.map +1 -1
- package/dist/__tests__/unit/package-config.test.js.map +1 -1
- package/dist/__tests__/unit/project-favicon.test.js.map +1 -1
- package/dist/__tests__/unit/prompt-vendoring.test.js.map +1 -1
- package/dist/__tests__/unit/risky-expression-scanner-sync.test.js.map +1 -1
- package/dist/__tests__/unit/seo-ai-schema.test.js.map +1 -1
- package/dist/__tests__/unit/snippet-scanner-sync.test.js.map +1 -1
- package/dist/__tests__/unit/spec-generation-notice.test.js.map +1 -1
- package/dist/__tests__/unit/templates-consistency.test.js.map +1 -1
- package/dist/__tests__/unit/validate-inert-config-keys.test.js.map +1 -1
- 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/__tests__/unit/validate-password-warning.test.js.map +1 -1
- package/dist/__tests__/unit/vendored-sync.test.js +11 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/commands/broken-links.js.map +1 -1
- package/dist/commands/clean.js.map +1 -1
- package/dist/commands/deploy/cloudflare.js.map +1 -1
- package/dist/commands/deploy.js +16 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/fix.d.ts.map +1 -1
- package/dist/commands/fix.js.map +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/deps.d.ts +4 -4
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +15 -10
- 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/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/find-project-dir.js.map +1 -1
- package/dist/lib/first-run-banner.js.map +1 -1
- package/dist/lib/mdx-validator.js.map +1 -1
- package/dist/lib/missing-description-scan.js.map +1 -1
- package/dist/utils/update-checker.js.map +1 -1
- package/package.json +9 -8
- 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/api/markdown-export/[project]/[...slug]/route.ts +65 -22
- package/vendored/app/api/raw-content/[...slug]/route.ts +21 -1
- package/vendored/app/layout.tsx +7 -0
- package/vendored/components/AIActionsMenu.tsx +19 -2
- 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/language-utils.ts +14 -2
- package/vendored/lib/layout-helpers.tsx +4 -0
- package/vendored/lib/mdx-inline-components.ts +1 -1
- package/vendored/lib/middleware-helpers.ts +27 -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 +76 -34
- package/vendored/lib/root-page-slug.ts +110 -0
- 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 +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
|
|
9
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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'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
|
-
|
|
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
|
|
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,
|
|
108
|
+
raw = await fetchMdxContent(project, requestedPath);
|
|
74
109
|
} catch (err) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
package/vendored/app/layout.tsx
CHANGED
|
@@ -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
|
-
|
|
293
|
-
|
|
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 && (
|