jamdesk 1.1.203 → 1.1.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -1
  2. package/dist/__tests__/integration/init.integration.test.js.map +1 -1
  3. package/dist/__tests__/integration/prepublish.integration.test.js.map +1 -1
  4. package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
  5. package/dist/__tests__/unit/banner-art-sync.test.js.map +1 -1
  6. package/dist/__tests__/unit/deploy-templates.test.js.map +1 -1
  7. package/dist/__tests__/unit/deploy.test.js +106 -0
  8. package/dist/__tests__/unit/deploy.test.js.map +1 -1
  9. package/dist/__tests__/unit/deprecated-components-sync.test.js.map +1 -1
  10. package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
  11. package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -1
  12. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  13. package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -1
  14. package/dist/__tests__/unit/frontmatter-sync.test.js.map +1 -1
  15. package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -1
  16. package/dist/__tests__/unit/inert-config-keys-sync.test.js.map +1 -1
  17. package/dist/__tests__/unit/init.test.js.map +1 -1
  18. package/dist/__tests__/unit/install-banner-gating.test.js.map +1 -1
  19. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts +22 -0
  20. package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts.map +1 -0
  21. package/dist/__tests__/unit/jwt-auth-config-drift.test.js +193 -0
  22. package/dist/__tests__/unit/jwt-auth-config-drift.test.js.map +1 -0
  23. package/dist/__tests__/unit/openapi-schema-patch-sync.test.js.map +1 -1
  24. package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js +1 -1
  25. package/dist/__tests__/unit/openapi.test.js.map +1 -1
  26. package/dist/__tests__/unit/package-config.test.js.map +1 -1
  27. package/dist/__tests__/unit/project-favicon.test.js.map +1 -1
  28. package/dist/__tests__/unit/prompt-vendoring.test.js.map +1 -1
  29. package/dist/__tests__/unit/risky-expression-scanner-sync.test.js.map +1 -1
  30. package/dist/__tests__/unit/seo-ai-schema.test.js.map +1 -1
  31. package/dist/__tests__/unit/snippet-scanner-sync.test.js.map +1 -1
  32. package/dist/__tests__/unit/spec-generation-notice.test.js.map +1 -1
  33. package/dist/__tests__/unit/templates-consistency.test.js.map +1 -1
  34. package/dist/__tests__/unit/validate-inert-config-keys.test.js.map +1 -1
  35. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts +2 -0
  36. package/dist/__tests__/unit/validate-jwt-auth.test.d.ts.map +1 -0
  37. package/dist/__tests__/unit/validate-jwt-auth.test.js +77 -0
  38. package/dist/__tests__/unit/validate-jwt-auth.test.js.map +1 -0
  39. package/dist/__tests__/unit/validate-password-warning.test.js.map +1 -1
  40. package/dist/__tests__/unit/vendored-sync.test.js +11 -0
  41. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  42. package/dist/commands/broken-links.js.map +1 -1
  43. package/dist/commands/clean.js.map +1 -1
  44. package/dist/commands/deploy/cloudflare.js.map +1 -1
  45. package/dist/commands/deploy.js +16 -0
  46. package/dist/commands/deploy.js.map +1 -1
  47. package/dist/commands/dev.js.map +1 -1
  48. package/dist/commands/fix.d.ts.map +1 -1
  49. package/dist/commands/fix.js.map +1 -1
  50. package/dist/commands/init.js.map +1 -1
  51. package/dist/commands/update.js.map +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/lib/auth.js.map +1 -1
  54. package/dist/lib/deps.d.ts +4 -4
  55. package/dist/lib/deps.d.ts.map +1 -1
  56. package/dist/lib/deps.js +15 -10
  57. package/dist/lib/deps.js.map +1 -1
  58. package/dist/lib/docs-config.d.ts +1 -0
  59. package/dist/lib/docs-config.d.ts.map +1 -1
  60. package/dist/lib/docs-config.js +49 -0
  61. package/dist/lib/docs-config.js.map +1 -1
  62. package/dist/lib/errors.d.ts.map +1 -1
  63. package/dist/lib/errors.js.map +1 -1
  64. package/dist/lib/find-project-dir.js.map +1 -1
  65. package/dist/lib/first-run-banner.js.map +1 -1
  66. package/dist/lib/mdx-validator.js.map +1 -1
  67. package/dist/lib/missing-description-scan.js.map +1 -1
  68. package/dist/utils/update-checker.js.map +1 -1
  69. package/package.json +9 -8
  70. package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +1 -7
  71. package/vendored/app/(jd-system)/jd-inactive/page.tsx +2 -4
  72. package/vendored/app/(unlock)/jd/auth/callback/CallbackClient.tsx +33 -0
  73. package/vendored/app/(unlock)/jd/auth/callback/page.tsx +15 -0
  74. package/vendored/app/(unlock)/jd/unlock/page.tsx +29 -7
  75. package/vendored/app/api/jd/auth/callback/route.ts +103 -0
  76. package/vendored/app/api/jd/auth/logout/route.ts +38 -0
  77. package/vendored/app/api/jd/auth/me/route.ts +52 -0
  78. package/vendored/app/api/jd/unlock/route.ts +26 -2
  79. package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +65 -22
  80. package/vendored/app/api/raw-content/[...slug]/route.ts +21 -1
  81. package/vendored/app/layout.tsx +7 -0
  82. package/vendored/components/AIActionsMenu.tsx +19 -2
  83. package/vendored/components/mdx/OpenApiEndpoint.tsx +4 -0
  84. package/vendored/components/navigation/Header.tsx +5 -0
  85. package/vendored/components/navigation/LogoutButton.tsx +41 -0
  86. package/vendored/lib/auth-resolver.ts +125 -50
  87. package/vendored/lib/cached-redis.ts +28 -3
  88. package/vendored/lib/docs-types.ts +17 -1
  89. package/vendored/lib/glob-match.ts +54 -0
  90. package/vendored/lib/jwt-auth-config.ts +37 -0
  91. package/vendored/lib/jwt-session.ts +98 -0
  92. package/vendored/lib/language-utils.ts +14 -2
  93. package/vendored/lib/layout-helpers.tsx +4 -0
  94. package/vendored/lib/mdx-inline-components.ts +1 -1
  95. package/vendored/lib/middleware-helpers.ts +27 -6
  96. package/vendored/lib/nav-group-filter.ts +81 -0
  97. package/vendored/lib/public-paths-resolver.ts +164 -7
  98. package/vendored/lib/r2-public-storage-guard.ts +26 -4
  99. package/vendored/lib/redis.ts +20 -2
  100. package/vendored/lib/render-doc-page.tsx +76 -34
  101. package/vendored/lib/root-page-slug.ts +110 -0
  102. package/vendored/lib/same-origin.ts +38 -0
  103. package/vendored/lib/sanitize-from.ts +31 -1
  104. package/vendored/lib/session-hint.ts +11 -0
  105. package/vendored/lib/snippet-compiler-isr.ts +1 -1
  106. package/vendored/lib/snippet-loader-isr.ts +1 -1
  107. package/vendored/lib/validate-config.ts +14 -0
  108. package/vendored/lib/viewer-navigation.ts +45 -0
  109. package/vendored/lib/visibility.ts +65 -0
  110. package/vendored/schema/docs-schema.json +25 -0
  111. package/vendored/shared/auth-cookie.ts +98 -5
  112. package/vendored/workspace-package-lock.json +474 -183
@@ -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
+ }
@@ -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
- return code in LANGUAGE_DISPLAY_NAMES;
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
- const direct = (LANGUAGE_DISPLAY_NAMES as Record<string, string>)[code];
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
  {/*
@@ -80,7 +80,7 @@ function transpileJsx(jsxSource: string): string {
80
80
  ],
81
81
  });
82
82
 
83
- if (!result.code) {
83
+ if (!result?.code) {
84
84
  throw new Error('Babel transpilation failed');
85
85
  }
86
86