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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { hasJwtSessionHint } from '../../lib/session-hint';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Only mounted when docs.json has auth.jwt.enabled. The jd_authed hint
|
|
8
|
+
* cookie (client-readable, set by the callback, cleared by logout) gates the
|
|
9
|
+
* /me fetch: the Header persists across next/link soft-navs, so this mounts
|
|
10
|
+
* once per hard load — without the hint, every fresh anonymous entry (SEO
|
|
11
|
+
* landing, shared link) would pay a /me round-trip. Login and logout both end
|
|
12
|
+
* in hard loads, so the state re-derives whenever it can change. The hint is
|
|
13
|
+
* advisory only — the signed session cookie behind /me is what's trusted.
|
|
14
|
+
* cursor-pointer per UI rule.
|
|
15
|
+
*/
|
|
16
|
+
export default function LogoutButton() {
|
|
17
|
+
const [visible, setVisible] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!hasJwtSessionHint(document.cookie)) return;
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
fetch('/_jd/api/auth/me', { credentials: 'same-origin' })
|
|
23
|
+
.then((r) => r.json())
|
|
24
|
+
.then((d: { authenticated?: boolean; authType?: string }) => {
|
|
25
|
+
if (!cancelled && d.authenticated && d.authType === 'jwt') setVisible(true);
|
|
26
|
+
})
|
|
27
|
+
.catch(() => { /* header must render regardless */ });
|
|
28
|
+
return () => { cancelled = true; };
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
if (!visible) return null;
|
|
32
|
+
return (
|
|
33
|
+
<a
|
|
34
|
+
href="/_jd/auth/logout"
|
|
35
|
+
className="cursor-pointer"
|
|
36
|
+
style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', whiteSpace: 'nowrap' }}
|
|
37
|
+
>
|
|
38
|
+
Log out
|
|
39
|
+
</a>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { redis } from './redis';
|
|
2
|
-
import { getProjectAuthSecret, getProjectAuthPublic } from './cached-redis';
|
|
3
|
-
import { matchPublicPath } from './glob-match';
|
|
2
|
+
import { getProjectAuthSecret, getProjectAuthPublic, getProjectJwtSecret } from './cached-redis';
|
|
3
|
+
import { matchPublicPath, resolveRequiredGroups } from './glob-match';
|
|
4
4
|
|
|
5
|
-
export { matchPublicPath };
|
|
5
|
+
export { matchPublicPath, resolveRequiredGroups };
|
|
6
6
|
|
|
7
7
|
export interface ResolvedAuth {
|
|
8
|
-
|
|
8
|
+
authType: 'password' | 'jwt';
|
|
9
|
+
hash?: string;
|
|
10
|
+
publicKeyPem?: string;
|
|
9
11
|
version: number;
|
|
10
12
|
publicPaths: string[];
|
|
13
|
+
loginUrl?: string;
|
|
14
|
+
groupsPaths?: Record<string, string[]>;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
interface AuthSecret {
|
|
@@ -15,30 +19,50 @@ interface AuthSecret {
|
|
|
15
19
|
version: number;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
interface JwtSecret {
|
|
23
|
+
publicKeyPem: string;
|
|
24
|
+
version: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
interface AuthPublic {
|
|
19
28
|
enabled?: boolean;
|
|
20
29
|
publicPaths?: string[];
|
|
30
|
+
authType?: 'password' | 'jwt';
|
|
31
|
+
loginUrl?: string;
|
|
32
|
+
groupsPaths?: Record<string, string[]>;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/**
|
|
24
|
-
* Resolve active
|
|
36
|
+
* Resolve active auth protection (password OR jwt) for a project.
|
|
37
|
+
*
|
|
38
|
+
* Truth table — the config plane's declared `authType` alone picks the
|
|
39
|
+
* ACTIVE mode; the matching secret key must back it or the gate is off:
|
|
40
|
+
* projectAuthPublic missing OR enabled!==true → no gate (return null)
|
|
41
|
+
* authType==='jwt' AND projectJwtSecret has a publicKeyPem → jwt gate
|
|
42
|
+
* authType==='jwt' AND projectJwtSecret missing/malformed → no gate (return null)
|
|
43
|
+
* authType!=='jwt' (password, incl. absent) AND projectAuthSecret has a hash → password gate
|
|
44
|
+
* authType!=='jwt' AND projectAuthSecret missing/malformed → no gate (return null)
|
|
25
45
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
46
|
+
* Coexistence is by design: BOTH secret keys may exist simultaneously
|
|
47
|
+
* during a password→JWT migration (the build writes the new key before
|
|
48
|
+
* flipping authType, and the old key isn't deleted). Only the
|
|
49
|
+
* build-written `authType` decides which secret is consulted — a
|
|
50
|
+
* password hash never satisfies jwt mode and vice versa, so there is no
|
|
51
|
+
* window where a stale secret of the wrong kind is honored.
|
|
30
52
|
*
|
|
31
|
-
*
|
|
32
|
-
* `AuthResolutionError` so middleware returns 503 instead of
|
|
33
|
-
* content without a check. A partial Upstash outage taking
|
|
34
|
-
* while the
|
|
53
|
+
* All three project-level keys fail-CLOSED: if any read throws, this
|
|
54
|
+
* throws `AuthResolutionError` so middleware returns 503 instead of
|
|
55
|
+
* serving gated content without a check. A partial Upstash outage taking
|
|
56
|
+
* down one key while the others are reachable would otherwise create a
|
|
57
|
+
* fail-open window.
|
|
35
58
|
*
|
|
36
|
-
* We intentionally do NOT read `domainAuthSecret` here
|
|
37
|
-
* mirrored on every
|
|
38
|
-
* so a failed mirror write can leave the domain key pointing
|
|
39
|
-
* version. Reading the mirror would then accept old session
|
|
40
|
-
* that host. Always reading the project-level key keeps a
|
|
41
|
-
* of truth and costs
|
|
59
|
+
* We intentionally do NOT read `domainAuthSecret`/`domainJwtSecret` here
|
|
60
|
+
* even though they are mirrored on every write. The dashboard fan-out is
|
|
61
|
+
* best-effort, so a failed mirror write can leave the domain key pointing
|
|
62
|
+
* at a stale version. Reading the mirror would then accept old session
|
|
63
|
+
* cookies on that host. Always reading the project-level key keeps a
|
|
64
|
+
* single source of truth and costs fewer Redis GETs per request on
|
|
65
|
+
* custom domains.
|
|
42
66
|
*
|
|
43
67
|
* When `redis` is null (local dev without Upstash configured), returns
|
|
44
68
|
* null immediately so the CLI dev server doesn't try to gate anything.
|
|
@@ -50,35 +74,33 @@ export class AuthResolutionError extends Error {
|
|
|
50
74
|
}
|
|
51
75
|
}
|
|
52
76
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let secret: AuthSecret | null;
|
|
59
|
-
let pub: AuthPublic | null;
|
|
60
|
-
|
|
77
|
+
// Every auth read shares one failure contract: any throw becomes an
|
|
78
|
+
// AuthResolutionError so callers fail CLOSED rather than treating an
|
|
79
|
+
// unreachable Redis as "not gated".
|
|
80
|
+
async function readOrFail<T>(read: () => Promise<unknown>): Promise<T | null> {
|
|
61
81
|
try {
|
|
62
|
-
|
|
63
|
-
getProjectAuthSecret(projectSlug),
|
|
64
|
-
getProjectAuthPublic(projectSlug),
|
|
65
|
-
]);
|
|
66
|
-
secret = secretResult as AuthSecret | null;
|
|
67
|
-
pub = publicResult as AuthPublic | null;
|
|
82
|
+
return (await read()) as T | null;
|
|
68
83
|
} catch (e) {
|
|
69
84
|
throw new AuthResolutionError(
|
|
70
85
|
'Failed to read project auth keys via cached-redis',
|
|
71
86
|
e,
|
|
72
87
|
);
|
|
73
88
|
}
|
|
89
|
+
}
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
export async function resolveAuth(
|
|
92
|
+
projectSlug: string,
|
|
93
|
+
): Promise<ResolvedAuth | null> {
|
|
94
|
+
if (!redis) return null;
|
|
95
|
+
|
|
96
|
+
// Public record FIRST: it decides whether any secret is needed at all, and
|
|
97
|
+
// which one. A non-gated tenant therefore costs one GET per cache miss
|
|
98
|
+
// (not three), and each mode reads only its own secret. The extra RTT on a
|
|
99
|
+
// gated tenant is paid once per 15s cache window, not per request.
|
|
100
|
+
// Fail-closed is unchanged: any throw becomes AuthResolutionError.
|
|
101
|
+
const pub = await readOrFail<AuthPublic>(
|
|
102
|
+
() => getProjectAuthPublic(projectSlug),
|
|
103
|
+
);
|
|
82
104
|
|
|
83
105
|
if (!pub || pub.enabled !== true) {
|
|
84
106
|
return null;
|
|
@@ -92,11 +114,54 @@ export async function resolveAuth(
|
|
|
92
114
|
const publicPaths: string[] = Array.isArray(pub.publicPaths)
|
|
93
115
|
? (pub.publicPaths as string[])
|
|
94
116
|
: [];
|
|
117
|
+
const authType: 'password' | 'jwt' = pub.authType === 'jwt' ? 'jwt' : 'password';
|
|
118
|
+
|
|
119
|
+
if (authType === 'jwt') {
|
|
120
|
+
const jwtSecret = await readOrFail<JwtSecret>(
|
|
121
|
+
() => getProjectJwtSecret(projectSlug),
|
|
122
|
+
);
|
|
123
|
+
if (
|
|
124
|
+
!jwtSecret ||
|
|
125
|
+
typeof jwtSecret.publicKeyPem !== 'string' ||
|
|
126
|
+
jwtSecret.publicKeyPem.length === 0 ||
|
|
127
|
+
typeof jwtSecret.version !== 'number'
|
|
128
|
+
) {
|
|
129
|
+
// authType says jwt but the jwt-own secret isn't there (or is
|
|
130
|
+
// malformed) — gate off. A lingering password hash from a prior
|
|
131
|
+
// mode must NEVER satisfy jwt mode; that would be a half-on gate.
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
authType,
|
|
136
|
+
publicKeyPem: jwtSecret.publicKeyPem,
|
|
137
|
+
version: jwtSecret.version,
|
|
138
|
+
publicPaths,
|
|
139
|
+
loginUrl: typeof pub.loginUrl === 'string' ? pub.loginUrl : undefined,
|
|
140
|
+
groupsPaths:
|
|
141
|
+
pub.groupsPaths && typeof pub.groupsPaths === 'object'
|
|
142
|
+
? pub.groupsPaths
|
|
143
|
+
: undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const secret = await readOrFail<AuthSecret>(
|
|
148
|
+
() => getProjectAuthSecret(projectSlug),
|
|
149
|
+
);
|
|
150
|
+
if (
|
|
151
|
+
!secret ||
|
|
152
|
+
typeof secret.hash !== 'string' ||
|
|
153
|
+
typeof secret.version !== 'number'
|
|
154
|
+
) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
95
157
|
|
|
96
158
|
return {
|
|
159
|
+
authType,
|
|
97
160
|
hash: secret.hash,
|
|
98
161
|
version: secret.version,
|
|
99
162
|
publicPaths,
|
|
163
|
+
loginUrl: undefined,
|
|
164
|
+
groupsPaths: undefined,
|
|
100
165
|
};
|
|
101
166
|
}
|
|
102
167
|
|
|
@@ -117,21 +182,31 @@ export async function resolveAuth(
|
|
|
117
182
|
* fetch mechanism. Therefore: fail OPEN (return false) on Redis error so
|
|
118
183
|
* brownouts don't nullify the CDN-cache latency win.
|
|
119
184
|
*
|
|
120
|
-
* Reads go through cached-redis (getProjectAuthSecret/getProjectAuthPublic
|
|
121
|
-
* the same wrappers resolveAuth uses — so the two
|
|
122
|
-
* entries for these keys instead of each holding
|
|
123
|
-
* does NOT cache fetch errors, so the fail-open
|
|
124
|
-
* the next call rather than pinning `false`.
|
|
185
|
+
* Reads go through cached-redis (getProjectAuthSecret/getProjectAuthPublic/
|
|
186
|
+
* getProjectJwtSecret) — the same wrappers resolveAuth uses — so the two
|
|
187
|
+
* share per-instance cache entries for these keys instead of each holding
|
|
188
|
+
* its own copy. cached-redis does NOT cache fetch errors, so the fail-open
|
|
189
|
+
* path below still retries on the next call rather than pinning `false`.
|
|
190
|
+
*
|
|
191
|
+
* Mirrors resolveAuth's mode binding exactly: a jwt tenant is gated only by
|
|
192
|
+
* its jwt key, a password tenant only by its hash. A lingering hash from a
|
|
193
|
+
* previous mode never counts (resolveAuth ignores it too), so the storage
|
|
194
|
+
* scheme and the edge gate can never disagree about whether a site is open.
|
|
125
195
|
*/
|
|
126
196
|
export async function isProjectGated(slug: string): Promise<boolean> {
|
|
127
197
|
if (!redis) return false;
|
|
128
198
|
|
|
129
199
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
200
|
+
// Same public-first ordering as resolveAuth (shared cache entries): a
|
|
201
|
+
// disabled tenant is one GET, and only the mode's own secret is read.
|
|
202
|
+
const pub = await getProjectAuthPublic(slug);
|
|
203
|
+
if (pub?.enabled !== true) return false;
|
|
204
|
+
if (pub.authType === 'jwt') {
|
|
205
|
+
const jwtSecret = await getProjectJwtSecret(slug);
|
|
206
|
+
return typeof jwtSecret?.publicKeyPem === 'string';
|
|
207
|
+
}
|
|
208
|
+
const secret = await getProjectAuthSecret(slug);
|
|
209
|
+
return typeof secret?.hash === 'string';
|
|
135
210
|
} catch {
|
|
136
211
|
// Fail OPEN: this is a read-mechanism choice, not access control.
|
|
137
212
|
// Gating already happened upstream in proxy.ts. Failing closed here
|
|
@@ -79,7 +79,15 @@ const projectMetaCache = new TtlCache<ProjectMeta>({ ttlMs: POSITIVE_TTL_MS, neg
|
|
|
79
79
|
// emergency rotation requires the JD_CACHE_DISABLED kill switch + redeploy.
|
|
80
80
|
// See docs/plans/2026-05-21-redis-in-function-memo-cache.md auto-memory.
|
|
81
81
|
const projectAuthSecretCache = new TtlCache<{ hash: string; version: number }>({ ttlMs: AUTH_TTL_MS, negativeTtlMs: NEGATIVE_TTL_MS });
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
export interface ProjectAuthPublic {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
publicPaths: string[];
|
|
86
|
+
authType?: 'password' | 'jwt';
|
|
87
|
+
loginUrl?: string;
|
|
88
|
+
groupsPaths?: Record<string, string[]>;
|
|
89
|
+
}
|
|
90
|
+
const projectAuthPublicCache = new TtlCache<ProjectAuthPublic>({ ttlMs: AUTH_TTL_MS, negativeTtlMs: NEGATIVE_TTL_MS });
|
|
83
91
|
|
|
84
92
|
const inFlight = new Map<string, Promise<unknown>>();
|
|
85
93
|
|
|
@@ -107,6 +115,7 @@ export function _resetForTest(): void {
|
|
|
107
115
|
projectMetaCache.clear();
|
|
108
116
|
projectAuthSecretCache.clear();
|
|
109
117
|
projectAuthPublicCache.clear();
|
|
118
|
+
projectJwtSecretCache.clear();
|
|
110
119
|
inFlight.clear();
|
|
111
120
|
_hits = 0; _misses = 0; _readsSinceLog = 0; _totalReadMs = 0;
|
|
112
121
|
_recent.length = 0; _recentIdx = 0; _recentSum = 0;
|
|
@@ -362,8 +371,24 @@ export async function getProjectAuthSecret(slug: string): Promise<{ hash: string
|
|
|
362
371
|
);
|
|
363
372
|
}
|
|
364
373
|
|
|
365
|
-
export async function getProjectAuthPublic(slug: string): Promise<
|
|
374
|
+
export async function getProjectAuthPublic(slug: string): Promise<ProjectAuthPublic | null> {
|
|
366
375
|
return readThrough(projectAuthPublicCache, `projectAuthPublic:${slug}`, () =>
|
|
367
|
-
redis!.get<
|
|
376
|
+
redis!.get<ProjectAuthPublic>(`projectAuthPublic:${slug}`),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// JWT-mode's own secret key — deliberately a SEPARATE cache/key from
|
|
381
|
+
// projectAuthSecret (password), never merged. Coexistence (both keys
|
|
382
|
+
// present during a password→JWT migration) is by design: authType alone
|
|
383
|
+
// (on the public plane) decides which one is consulted.
|
|
384
|
+
export interface ProjectJwtSecret {
|
|
385
|
+
publicKeyPem: string;
|
|
386
|
+
version: number;
|
|
387
|
+
}
|
|
388
|
+
const projectJwtSecretCache = new TtlCache<ProjectJwtSecret>({ ttlMs: AUTH_TTL_MS, negativeTtlMs: NEGATIVE_TTL_MS });
|
|
389
|
+
|
|
390
|
+
export async function getProjectJwtSecret(slug: string): Promise<ProjectJwtSecret | null> {
|
|
391
|
+
return readThrough(projectJwtSecretCache, `projectJwtSecret:${slug}`, () =>
|
|
392
|
+
redis!.get<ProjectJwtSecret>(`projectJwtSecret:${slug}`),
|
|
368
393
|
);
|
|
369
394
|
}
|
|
@@ -846,12 +846,28 @@ export interface PasswordAuthConfig {
|
|
|
846
846
|
private?: string[];
|
|
847
847
|
}
|
|
848
848
|
|
|
849
|
+
/**
|
|
850
|
+
* JWT authentication config. The verification key is generated and stored via
|
|
851
|
+
* the Jamdesk dashboard; this block only carries declarative opt-in, the
|
|
852
|
+
* customer login URL, and public path exceptions.
|
|
853
|
+
*/
|
|
854
|
+
export interface JwtAuthConfig {
|
|
855
|
+
/** Opt in to JWT auth. Site is only gated when this is true AND a signing key has been generated in the dashboard. */
|
|
856
|
+
enabled?: boolean;
|
|
857
|
+
/** Absolute https:// URL of your login flow. Unauthenticated visitors are redirected here with ?redirect=<path>. */
|
|
858
|
+
loginUrl?: string;
|
|
859
|
+
/** Paths or globs that bypass authentication. Supports '*' and '**'. */
|
|
860
|
+
public?: string[];
|
|
861
|
+
}
|
|
862
|
+
|
|
849
863
|
/**
|
|
850
864
|
* Auth configuration for access control. Currently supports shared-password
|
|
851
|
-
* protection; additional mechanisms may be added in
|
|
865
|
+
* protection and JWT authentication; additional mechanisms may be added in
|
|
866
|
+
* future.
|
|
852
867
|
*/
|
|
853
868
|
export interface AuthConfig {
|
|
854
869
|
password?: PasswordAuthConfig;
|
|
870
|
+
jwt?: JwtAuthConfig;
|
|
855
871
|
}
|
|
856
872
|
|
|
857
873
|
/**
|
|
@@ -76,8 +76,62 @@ export function matchPublicPath(
|
|
|
76
76
|
normalized = pathname.slice(5); // '/docs/' → '/' (keep leading slash)
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
// The main gate runs on the pretty path BEFORE the `.md` → markdown-export
|
|
80
|
+
// rewrite, so `/changelog.md` must match a public `/changelog` — otherwise
|
|
81
|
+
// the export of a public page over-gates while the HTML page is open.
|
|
82
|
+
// (resolveRequiredGroups strips the same suffix for the group lookup.)
|
|
83
|
+
if (normalized.length > 3 && normalized.endsWith('.md')) {
|
|
84
|
+
normalized = normalized.slice(0, -3);
|
|
85
|
+
}
|
|
79
86
|
|
|
80
87
|
const { literals, regexes } = compilePublicPaths(publicPaths);
|
|
81
88
|
if (literals.has(normalized)) return true;
|
|
82
89
|
return regexes.some((re) => re.test(normalized));
|
|
83
90
|
}
|
|
91
|
+
|
|
92
|
+
const R2_CONTENT_RE = /^\/api\/r2\/[^/]+\/content\/(.+?)(?:\.mdx)?$/;
|
|
93
|
+
// Direct hit on the raw-markdown export route: /api/markdown-export/{slug}/{page}.
|
|
94
|
+
// The captured group is the root-relative page path (the middleware rewrite
|
|
95
|
+
// already stripped any /docs prefix), so — like the /api/r2 branch — this shape
|
|
96
|
+
// carries page content and must resolve group restrictions on it. Without this,
|
|
97
|
+
// a direct external hit would pass the edge gate with no group requirement
|
|
98
|
+
// resolved (the path doesn't look like a page path) and leak group-restricted
|
|
99
|
+
// MDX to any authenticated user (and, gated on the wrong slug, cross-tenant).
|
|
100
|
+
const MARKDOWN_EXPORT_RE = /^\/api\/markdown-export\/[^/]+\/(.+?)(?:\.md)?$/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Look up the required groups for a request path against the build-written
|
|
104
|
+
* groupsPaths map (keys are root-relative page slugs). Normalizes every path
|
|
105
|
+
* shape that serves page content: percent-encoding, hostAtDocs /docs prefix,
|
|
106
|
+
* trailing slash, markdown-export .md suffix, /api/r2 raw-MDX paths, and direct
|
|
107
|
+
* /api/markdown-export hits. Returns the required groups, or null when the path
|
|
108
|
+
* carries no restriction.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveRequiredGroups(
|
|
111
|
+
pathname: string,
|
|
112
|
+
groupsPaths: Record<string, string[]>,
|
|
113
|
+
hostAtDocs: boolean,
|
|
114
|
+
): string[] | null {
|
|
115
|
+
let p = pathname;
|
|
116
|
+
try { p = decodeURIComponent(p); } catch { /* keep raw — never throw in the gate */ }
|
|
117
|
+
|
|
118
|
+
const r2 = p.match(R2_CONTENT_RE);
|
|
119
|
+
const mdExport = p.match(MARKDOWN_EXPORT_RE);
|
|
120
|
+
if (r2) {
|
|
121
|
+
p = `/${r2[1]}`;
|
|
122
|
+
} else if (mdExport) {
|
|
123
|
+
// Page path already root-relative (rewrite stripped /docs); no hostAtDocs
|
|
124
|
+
// normalization, matching the /api/r2 branch.
|
|
125
|
+
p = `/${mdExport[1]}`;
|
|
126
|
+
} else {
|
|
127
|
+
if (hostAtDocs) {
|
|
128
|
+
if (p === '/docs') p = '/';
|
|
129
|
+
else if (p.startsWith('/docs/')) p = p.slice(5);
|
|
130
|
+
}
|
|
131
|
+
if (p.endsWith('.md')) p = p.slice(0, -3);
|
|
132
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const required = groupsPaths[p];
|
|
136
|
+
return Array.isArray(required) && required.length > 0 ? required : null;
|
|
137
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DocsConfig } from './docs-types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure validation for the auth.jwt block. Returns config_error strings.
|
|
5
|
+
* Kept out of validate-config.ts so the CLI's parallel validator and the
|
|
6
|
+
* dashboard can reuse identical logic without dragging in Ajv.
|
|
7
|
+
*/
|
|
8
|
+
export function validateJwtAuth(config: DocsConfig): string[] {
|
|
9
|
+
const auth = config.auth as
|
|
10
|
+
| { password?: { enabled?: unknown }; jwt?: { enabled?: unknown; loginUrl?: unknown; public?: unknown } }
|
|
11
|
+
| undefined;
|
|
12
|
+
const jwt = auth?.jwt;
|
|
13
|
+
if (!jwt) return [];
|
|
14
|
+
|
|
15
|
+
const errors: string[] = [];
|
|
16
|
+
if (auth?.password?.enabled === true && jwt.enabled === true) {
|
|
17
|
+
errors.push('auth.password and auth.jwt cannot both be enabled — choose one authentication method');
|
|
18
|
+
return errors;
|
|
19
|
+
}
|
|
20
|
+
if (jwt.enabled === true) {
|
|
21
|
+
if (typeof jwt.loginUrl !== 'string' || jwt.loginUrl.length === 0) {
|
|
22
|
+
errors.push('auth.jwt.enabled requires auth.jwt.loginUrl (an absolute https:// URL of your login flow)');
|
|
23
|
+
} else {
|
|
24
|
+
let ok = false;
|
|
25
|
+
try {
|
|
26
|
+
ok = new URL(jwt.loginUrl).protocol === 'https:';
|
|
27
|
+
} catch { /* unparseable */ }
|
|
28
|
+
if (!ok) errors.push('auth.jwt.loginUrl must be an absolute https:// URL');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (jwt.public !== undefined) {
|
|
32
|
+
const valid = Array.isArray(jwt.public) &&
|
|
33
|
+
jwt.public.every((p) => typeof p === 'string' && p.startsWith('/'));
|
|
34
|
+
if (!valid) errors.push('auth.jwt.public must be an array of path strings starting with /');
|
|
35
|
+
}
|
|
36
|
+
return errors;
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { importSPKI, jwtVerify } from 'jose';
|
|
2
|
+
import { MAX_GROUPS_PER_PAGE, MAX_GROUP_NAME_LEN } from './public-paths-resolver.js';
|
|
3
|
+
|
|
4
|
+
// Node-runtime only (callback/me routes). NOT edge-safe by policy — the edge
|
|
5
|
+
// never verifies JWTs; it verifies the HMAC session cookie minted here.
|
|
6
|
+
export const DEFAULT_SESSION_MS = 7 * 24 * 3600 * 1000;
|
|
7
|
+
export const MAX_SESSION_MS = 30 * 24 * 3600 * 1000;
|
|
8
|
+
export const MAX_API_INPUTS_BYTES = 2048;
|
|
9
|
+
export const MAX_TOKEN_EXP_AHEAD_SEC = 600;
|
|
10
|
+
// Token-side group limits are the SAME constants the build applies to
|
|
11
|
+
// frontmatter `groups:` — a token can never carry more (or longer) group
|
|
12
|
+
// names than a page can require, so the two sides cannot drift apart.
|
|
13
|
+
const MAX_GROUPS = MAX_GROUPS_PER_PAGE;
|
|
14
|
+
const MAX_GROUP_LEN = MAX_GROUP_NAME_LEN;
|
|
15
|
+
const API_INPUT_SECTIONS = new Set(['server', 'header', 'query', 'cookie', 'path']);
|
|
16
|
+
|
|
17
|
+
export interface ValidatedJwtUser {
|
|
18
|
+
sessionExpiresAt: number; // ms since epoch
|
|
19
|
+
groups: string[];
|
|
20
|
+
apiPlaygroundInputs?: Record<string, unknown>;
|
|
21
|
+
apiInputsDropped: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type JwtVerifyOutcome =
|
|
25
|
+
| { ok: true; user: ValidatedJwtUser }
|
|
26
|
+
| { ok: false; reason: string };
|
|
27
|
+
|
|
28
|
+
export async function verifyDocsJwt(
|
|
29
|
+
token: string,
|
|
30
|
+
publicKeyPem: string,
|
|
31
|
+
requestHost: string,
|
|
32
|
+
): Promise<JwtVerifyOutcome> {
|
|
33
|
+
if (!token || !publicKeyPem || !requestHost) return { ok: false, reason: 'missing-input' };
|
|
34
|
+
|
|
35
|
+
let payload: Record<string, unknown>;
|
|
36
|
+
try {
|
|
37
|
+
const key = await importSPKI(publicKeyPem, 'EdDSA');
|
|
38
|
+
// requireExp: customers must set a short exp (docs recommend ≤10s); jose
|
|
39
|
+
// rejects both missing and passed exp. Small clockTolerance absorbs skew
|
|
40
|
+
// between the customer backend and Vercel.
|
|
41
|
+
const result = await jwtVerify(token, key, { algorithms: ['EdDSA'], clockTolerance: 30, requiredClaims: ['exp'] });
|
|
42
|
+
payload = result.payload as Record<string, unknown>;
|
|
43
|
+
} catch {
|
|
44
|
+
return { ok: false, reason: 'invalid-token' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Replay bound: the handshake token must be short-lived (docs recommend
|
|
48
|
+
// exp ≤10s; we allow up to 10 min for slow clocks/flows). A token that
|
|
49
|
+
// validates today with exp a year out would otherwise be replayable until
|
|
50
|
+
// then — exp is the ONLY replay control (no jti store, matching Mintlify).
|
|
51
|
+
const expSec = payload.exp as number; // present: requiredClaims enforced it
|
|
52
|
+
if (expSec - Date.now() / 1000 > MAX_TOKEN_EXP_AHEAD_SEC) {
|
|
53
|
+
return { ok: false, reason: 'exp-too-far' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// host binding prevents a token minted for one docs site (or grabbed from
|
|
57
|
+
// another tenant's flow) being replayed against a different host.
|
|
58
|
+
if (typeof payload.host !== 'string' || payload.host.toLowerCase() !== requestHost.toLowerCase()) {
|
|
59
|
+
return { ok: false, reason: 'host-mismatch' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let groups: string[] = [];
|
|
63
|
+
if (payload.groups !== undefined) {
|
|
64
|
+
if (!Array.isArray(payload.groups) ||
|
|
65
|
+
payload.groups.length > MAX_GROUPS ||
|
|
66
|
+
!payload.groups.every((g) => typeof g === 'string' && g.length > 0 && g.length <= MAX_GROUP_LEN)) {
|
|
67
|
+
return { ok: false, reason: 'invalid-groups' };
|
|
68
|
+
}
|
|
69
|
+
groups = payload.groups as string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let sessionExpiresAt = Date.now() + DEFAULT_SESSION_MS;
|
|
73
|
+
if (payload.expiresAt !== undefined) {
|
|
74
|
+
const sec = Number(payload.expiresAt);
|
|
75
|
+
if (!Number.isSafeInteger(sec) || sec * 1000 <= Date.now()) {
|
|
76
|
+
return { ok: false, reason: 'invalid-expiresAt' };
|
|
77
|
+
}
|
|
78
|
+
sessionExpiresAt = Math.min(sec * 1000, Date.now() + MAX_SESSION_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let apiPlaygroundInputs: Record<string, unknown> | undefined;
|
|
82
|
+
let apiInputsDropped = false;
|
|
83
|
+
const rawApi = payload.apiPlaygroundInputs;
|
|
84
|
+
if (rawApi && typeof rawApi === 'object' && !Array.isArray(rawApi)) {
|
|
85
|
+
const filtered: Record<string, unknown> = {};
|
|
86
|
+
for (const [k, v] of Object.entries(rawApi as Record<string, unknown>)) {
|
|
87
|
+
if (API_INPUT_SECTIONS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) filtered[k] = v;
|
|
88
|
+
}
|
|
89
|
+
const serialized = JSON.stringify(filtered);
|
|
90
|
+
if (Object.keys(filtered).length > 0 && serialized.length <= MAX_API_INPUTS_BYTES) {
|
|
91
|
+
apiPlaygroundInputs = filtered;
|
|
92
|
+
} else if (Object.keys(filtered).length > 0) {
|
|
93
|
+
apiInputsDropped = true; // too big: session still granted, playground pre-fill lost
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { ok: true, user: { sessionExpiresAt, groups, apiPlaygroundInputs, apiInputsDropped } };
|
|
98
|
+
}
|
|
@@ -340,7 +340,13 @@ export function transformLanguagePath(
|
|
|
340
340
|
* may carry any casing — use isKnownLanguageCode instead.
|
|
341
341
|
*/
|
|
342
342
|
export function isValidLanguageCode(code: string): code is LanguageCode {
|
|
343
|
-
|
|
343
|
+
// `Object.hasOwn`, not `in`: LANGUAGE_DISPLAY_NAMES is a plain object, so
|
|
344
|
+
// `in` also answered true for every Object.prototype key. That made
|
|
345
|
+
// `constructor`, `toString`, `valueOf` and friends read as language codes —
|
|
346
|
+
// and because needsSlugRewrite rewrites a single-segment alias BEFORE the
|
|
347
|
+
// literal path is tried, a tenant with a real page named `constructor` could
|
|
348
|
+
// not reach it in HTML at all.
|
|
349
|
+
return Object.hasOwn(LANGUAGE_DISPLAY_NAMES, code);
|
|
344
350
|
}
|
|
345
351
|
|
|
346
352
|
/** Lowercase → canonical lookup, built once. Lets `extractLanguageFromPath`
|
|
@@ -379,7 +385,13 @@ export function isKnownLanguageCode(code: string): boolean {
|
|
|
379
385
|
* the bare code. Used by llms.txt cross-language links.
|
|
380
386
|
*/
|
|
381
387
|
export function languageDisplayName(code: string): string {
|
|
382
|
-
|
|
388
|
+
// hasOwn-guarded for the same reason as isValidLanguageCode, but the failure
|
|
389
|
+
// here is worse than a false positive: a bare `[code]` read of 'toString'
|
|
390
|
+
// returns Object.prototype.toString — a FUNCTION handed back through a
|
|
391
|
+
// `: string` signature, straight into llms.txt link text.
|
|
392
|
+
const direct = Object.hasOwn(LANGUAGE_DISPLAY_NAMES, code)
|
|
393
|
+
? (LANGUAGE_DISPLAY_NAMES as Record<string, string>)[code]
|
|
394
|
+
: undefined;
|
|
383
395
|
if (direct) return direct;
|
|
384
396
|
const canonical = LANGUAGE_CODE_BY_LOWER.get(code.toLowerCase());
|
|
385
397
|
return canonical ? LANGUAGE_DISPLAY_NAMES[canonical] : code;
|
|
@@ -531,6 +531,10 @@ export async function DocsChrome({
|
|
|
531
531
|
<link key={`pc-${origin}`} rel="preconnect" href={origin} />
|
|
532
532
|
))}
|
|
533
533
|
{cmpScriptSrcs.map((src) => (
|
|
534
|
+
// Load-bearing: an auto-blocking CMP must run synchronously and
|
|
535
|
+
// BEFORE any later script, or it cannot intercept them. next/script
|
|
536
|
+
// defers, which silently breaks consent gating.
|
|
537
|
+
// eslint-disable-next-line @next/next/no-sync-scripts
|
|
534
538
|
<script key={src} src={src} />
|
|
535
539
|
))}
|
|
536
540
|
{/*
|