jamdesk 1.1.204 → 1.1.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/__tests__/unit/deploy.test.js +106 -0
  2. package/dist/__tests__/unit/deploy.test.js.map +1 -1
  3. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts +22 -0
  4. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/jwt-auth-config-drift.test.js +193 -0
  6. package/dist/__tests__/unit/jwt-auth-config-drift.test.js.map +1 -0
  7. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts +2 -0
  8. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts.map +1 -0
  9. package/dist/__tests__/unit/validate-jwt-auth.test.js +77 -0
  10. package/dist/__tests__/unit/validate-jwt-auth.test.js.map +1 -0
  11. package/dist/commands/deploy.js +16 -0
  12. package/dist/commands/deploy.js.map +1 -1
  13. package/dist/lib/deps.d.ts.map +1 -1
  14. package/dist/lib/deps.js +5 -0
  15. package/dist/lib/deps.js.map +1 -1
  16. package/dist/lib/docs-config.d.ts +1 -0
  17. package/dist/lib/docs-config.d.ts.map +1 -1
  18. package/dist/lib/docs-config.js +49 -0
  19. package/dist/lib/docs-config.js.map +1 -1
  20. package/package.json +1 -1
  21. package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +1 -7
  22. package/vendored/app/(jd-system)/jd-inactive/page.tsx +2 -4
  23. package/vendored/app/(unlock)/jd/auth/callback/CallbackClient.tsx +33 -0
  24. package/vendored/app/(unlock)/jd/auth/callback/page.tsx +15 -0
  25. package/vendored/app/(unlock)/jd/unlock/page.tsx +29 -7
  26. package/vendored/app/api/jd/auth/callback/route.ts +103 -0
  27. package/vendored/app/api/jd/auth/logout/route.ts +38 -0
  28. package/vendored/app/api/jd/auth/me/route.ts +52 -0
  29. package/vendored/app/api/jd/unlock/route.ts +26 -2
  30. package/vendored/app/layout.tsx +7 -0
  31. package/vendored/components/mdx/OpenApiEndpoint.tsx +4 -0
  32. package/vendored/components/navigation/Header.tsx +5 -0
  33. package/vendored/components/navigation/LogoutButton.tsx +41 -0
  34. package/vendored/lib/auth-resolver.ts +125 -50
  35. package/vendored/lib/cached-redis.ts +28 -3
  36. package/vendored/lib/docs-types.ts +17 -1
  37. package/vendored/lib/glob-match.ts +54 -0
  38. package/vendored/lib/jwt-auth-config.ts +37 -0
  39. package/vendored/lib/jwt-session.ts +98 -0
  40. package/vendored/lib/mdx-inline-components.ts +1 -1
  41. package/vendored/lib/middleware-helpers.ts +11 -6
  42. package/vendored/lib/nav-group-filter.ts +81 -0
  43. package/vendored/lib/public-paths-resolver.ts +164 -7
  44. package/vendored/lib/r2-public-storage-guard.ts +26 -4
  45. package/vendored/lib/redis.ts +20 -2
  46. package/vendored/lib/render-doc-page.tsx +74 -13
  47. package/vendored/lib/root-page-slug.ts +20 -1
  48. package/vendored/lib/same-origin.ts +38 -0
  49. package/vendored/lib/sanitize-from.ts +31 -1
  50. package/vendored/lib/session-hint.ts +11 -0
  51. package/vendored/lib/snippet-compiler-isr.ts +1 -1
  52. package/vendored/lib/snippet-loader-isr.ts +1 -1
  53. package/vendored/lib/validate-config.ts +14 -0
  54. package/vendored/lib/viewer-navigation.ts +45 -0
  55. package/vendored/lib/visibility.ts +65 -0
  56. package/vendored/schema/docs-schema.json +25 -0
  57. package/vendored/shared/auth-cookie.ts +98 -5
  58. package/vendored/workspace-package-lock.json +73 -63
@@ -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
@@ -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',
@@ -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 && (
@@ -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
- hash: string;
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 password protection for a project.
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
- * Truth table (both sides must agree for gating to be active):
27
- * projectAuthPublic.enabled=true AND projectAuthSecret has a hash → gate
28
- * projectAuthPublic missing OR enabled!==true → no gate (return null)
29
- * projectAuthSecret missing → no gate (return null)
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
- * Both project-level keys fail-CLOSED: if either read throws, this throws
32
- * `AuthResolutionError` so middleware returns 503 instead of serving gated
33
- * content without a check. A partial Upstash outage taking down one key
34
- * while the other is reachable would otherwise create a fail-open window.
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 even though it is
37
- * mirrored on every password write. The dashboard fan-out is best-effort,
38
- * so a failed mirror write can leave the domain key pointing at a stale
39
- * version. Reading the mirror would then accept old session cookies on
40
- * that host. Always reading the project-level key keeps a single source
41
- * of truth and costs one fewer Redis GET per request on custom domains.
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
- export async function resolveAuth(
54
- projectSlug: string,
55
- ): Promise<ResolvedAuth | null> {
56
- if (!redis) return null;
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
- const [secretResult, publicResult] = await Promise.all([
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
- if (
76
- !secret ||
77
- typeof secret.hash !== 'string' ||
78
- typeof secret.version !== 'number'
79
- ) {
80
- return null;
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 share per-instance cache
122
- * entries for these keys instead of each holding its own copy. cached-redis
123
- * does NOT cache fetch errors, so the fail-open path below still retries on
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
- const [secret, pub] = await Promise.all([
131
- getProjectAuthSecret(slug),
132
- getProjectAuthPublic(slug),
133
- ]);
134
- return !!secret && typeof secret.hash === 'string' && pub?.enabled === true;
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
- const projectAuthPublicCache = new TtlCache<{ enabled: boolean; publicPaths: string[] }>({ ttlMs: AUTH_TTL_MS, negativeTtlMs: NEGATIVE_TTL_MS });
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<{ enabled: boolean; publicPaths: string[] } | null> {
374
+ export async function getProjectAuthPublic(slug: string): Promise<ProjectAuthPublic | null> {
366
375
  return readThrough(projectAuthPublicCache, `projectAuthPublic:${slug}`, () =>
367
- redis!.get<{ enabled: boolean; publicPaths: string[] }>(`projectAuthPublic:${slug}`),
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 future.
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
+ }