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
@@ -206,6 +206,12 @@ export async function handleProjectResolution(
206
206
  projectSlug,
207
207
  hostAtDocs: customResolution.hostAtDocs,
208
208
  inactive,
209
+ // A forwarded custom domain IS a custom domain for the D9
210
+ // retained-domains gate — a customer proxying through their own
211
+ // Cloudflare Worker must keep serving like a direct CNAME hit,
212
+ // not fall back to the *.jamdesk.app placeholder (review
213
+ // finding #4).
214
+ viaCustomDomain: true,
209
215
  // Subpath follows the same source as hostAtDocs — the DOMAIN's own
210
216
  // config. A domain served at /help must not inherit the subdomain's
211
217
  // projectCfg prefix.
@@ -266,16 +272,15 @@ export async function handleProjectResolution(
266
272
  };
267
273
  }
268
274
 
269
- // Piggyback inactive read on the custom-domain resolution path. Single
270
- // extra Redis GET per request; the proxy reads .inactive to rewrite.
271
- const inactive = await getProjectInactive(resolution.projectSlug);
272
-
273
- log('info', 'Project resolved from custom domain', { hostname, projectSlug: resolution.projectSlug, inactive });
275
+ // D9: viaCustomDomain is always true on this path, and proxy.ts only
276
+ // reads .inactive gated on `!viaCustomDomain` — so the value would
277
+ // always be discarded. Skip the Redis GET entirely rather than pay for
278
+ // a lookup no caller can ever use (review finding #8).
279
+ log('info', 'Project resolved from custom domain', { hostname, projectSlug: resolution.projectSlug });
274
280
  return {
275
281
  projectSlug: resolution.projectSlug,
276
282
  hostAtDocs: resolution.hostAtDocs,
277
283
  domainStatus: resolution.domainStatus,
278
- inactive,
279
284
  viaCustomDomain: true,
280
285
  docsSubpath: resolution.docsSubpath,
281
286
  previousDocsSubpath: resolution.previousDocsSubpath,
@@ -768,6 +773,22 @@ export function getDocsPrefixRouting(
768
773
  if (pathname === externalPrefix || pathname.startsWith(`${externalPrefix}/`)) {
769
774
  return { rewrite: toInternalPath(pathname, externalPrefix) };
770
775
  }
776
+ // `<prefix>.md` is the prefix ROOT's markdown export, not a dotted asset. It
777
+ // is the shape our own docs tell agents to use ("append .md to any page
778
+ // URL"), and it is the only `.md` on a custom-subpath tenant that was
779
+ // broken: `/fun/index.md`, `/fun/introduction.md` and `/fun` with
780
+ // `Accept: text/markdown` all worked, because each either sits under
781
+ // `<prefix>/` or carries no dot. `/fun.md` matched neither branch above,
782
+ // fell through the asset guard below untouched, and reached the markdown
783
+ // branch as pagePath `/fun` — which doesn't start with `/docs`, so the root
784
+ // sentinel strip never ran and the slug `fun` missed in R2.
785
+ //
786
+ // Internalise to the canonical `/docs.md`, the shape that branch already
787
+ // strips to the sentinel. Only `.md` is special-cased: `<prefix>.json` and
788
+ // friends stay assets, which is what the guard below is for.
789
+ if (pathname === `${externalPrefix}.md`) {
790
+ return { rewrite: `${DEFAULT_DOCS_SUBPATH}.md` };
791
+ }
771
792
  // Serve-both: internal-canonical /docs paths pass through untouched — a
772
793
  // customer worker still routing the old /docs prefix keeps working, and
773
794
  // request-time canonicals point at the current prefix (no dup content).
@@ -0,0 +1,81 @@
1
+ import type { NavigationConfig } from './docs-types.js';
2
+
3
+ /**
4
+ * Strip group-restricted pages the viewer cannot access out of a navigation
5
+ * config before it is serialized to the client.
6
+ *
7
+ * Why: the sidebar/tabs/header are client components that resolve navigation
8
+ * from the full docs.json config, so every page's RSC payload — public pages
9
+ * served to anonymous visitors included — would otherwise carry the titles and
10
+ * hrefs of group-restricted pages. The edge answers an out-of-group request
11
+ * with a 404 precisely so the page's existence is not confirmed; listing it in
12
+ * the nav would contradict that.
13
+ *
14
+ * Semantics mirror the edge gate (any-of): a page stays when the viewer holds
15
+ * at least one of its required groups. `groupsPaths` keys are root-relative
16
+ * slugs with a leading slash (collectGroupsPaths); nav entries may omit it.
17
+ * Returns the input object itself when nothing is restricted, so callers can
18
+ * pass it through without re-render churn; never mutates the input.
19
+ */
20
+ export function filterNavigationForGroups(
21
+ navigation: NavigationConfig,
22
+ groupsPaths: Record<string, string[]> | undefined,
23
+ userGroups: readonly string[],
24
+ ): NavigationConfig {
25
+ if (!groupsPaths || Object.keys(groupsPaths).length === 0) return navigation;
26
+ const userSet = new Set(userGroups);
27
+ const isHidden = (page: string): boolean => {
28
+ const key = page.startsWith('/') ? page : `/${page}`;
29
+ const required = groupsPaths[key];
30
+ if (!Array.isArray(required) || required.length === 0) return false;
31
+ return !required.some((g) => userSet.has(g));
32
+ };
33
+ return walk(navigation, isHidden) as NavigationConfig;
34
+ }
35
+
36
+ // Generic structural walk: any `pages` array is filtered (string entries and
37
+ // `{ page }` objects), and every other array/object is descended into so
38
+ // tabs, anchors, languages, versions, dropdowns and nested groups are all
39
+ // covered without enumerating the schema here. Containers (groups, tabs,
40
+ // languages, versions, …) that held pages/groups and end up with none are
41
+ // dropped too — a bare "Internal Admin" header discloses as much as the
42
+ // page titles under it would have.
43
+ const CONTAINER_KEYS = ['pages', 'groups'] as const;
44
+
45
+ function walk(node: unknown, isHidden: (page: string) => boolean): unknown {
46
+ if (Array.isArray(node)) {
47
+ return node
48
+ .map((n) => walk(n, isHidden))
49
+ .filter((n) => !becameEmpty(n));
50
+ }
51
+ if (!node || typeof node !== 'object') return node;
52
+ const out: Record<string, unknown> = {};
53
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
54
+ if (key === 'pages' && Array.isArray(value)) {
55
+ out[key] = (walk(
56
+ value.filter((entry) => {
57
+ if (typeof entry === 'string') return !isHidden(entry);
58
+ if (entry && typeof entry === 'object' && typeof (entry as { page?: unknown }).page === 'string') {
59
+ return !isHidden((entry as { page: string }).page);
60
+ }
61
+ return true;
62
+ }),
63
+ isHidden,
64
+ ) as unknown[]);
65
+ } else {
66
+ out[key] = walk(value, isHidden);
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ // True for an object that carries a `pages`/`groups` array and every such
73
+ // array is now empty. Objects without any container key (page entries with
74
+ // `{ page }`, anchors, plain hrefs) are never considered empty.
75
+ function becameEmpty(node: unknown): boolean {
76
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
77
+ const rec = node as Record<string, unknown>;
78
+ const containers = CONTAINER_KEYS.filter((k) => Array.isArray(rec[k]));
79
+ if (containers.length === 0) return false;
80
+ return containers.every((k) => (rec[k] as unknown[]).length === 0);
81
+ }
@@ -1,6 +1,7 @@
1
1
  import type { DocsConfig } from './docs-types.js';
2
2
  import { matchPublicPath } from './glob-match.js';
3
3
  import { findFirstNavPage } from './find-first-nav-page.js';
4
+ import { isKnownLanguageCode } from './language-utils.js';
4
5
 
5
6
  export { findFirstNavPage };
6
7
 
@@ -60,7 +61,17 @@ function collectPublicGroupPages(
60
61
  * 2. Navigation groups with `public: true` in docs.json
61
62
  * 3. `auth.password.public[]` globs in docs.json
62
63
  */
63
- export function resolvePublicPaths(input: Input): string[] {
64
+ /**
65
+ * `groupsPaths` (jwt mode only): slugs to subtract — see the comment at the
66
+ * subtraction site. Omitted in password/specific/off mode, where the edge
67
+ * never enforces groups: subtracting there only turns a page the author
68
+ * marked `public: true` into a password-gated one, and hides that `groups`
69
+ * is being ignored. build.ts warns about that case instead.
70
+ */
71
+ export function resolvePublicPaths(
72
+ input: Input,
73
+ groupsPaths?: Record<string, string[]>,
74
+ ): string[] {
64
75
  const set = new Set<string>();
65
76
 
66
77
  for (const p of input.pagesWithFrontmatter) {
@@ -82,9 +93,130 @@ export function resolvePublicPaths(input: Input): string[] {
82
93
  }
83
94
  }
84
95
 
96
+ // Gated on enabled: a disabled jwt block's public[] must not punch holes in
97
+ // a password-protected site (password globs stay unconditional above —
98
+ // 'specific' mode legitimately reads them while password.enabled is false).
99
+ const jwtCfg = input.docsConfig.auth?.jwt;
100
+ if (jwtCfg?.enabled === true && Array.isArray(jwtCfg.public)) {
101
+ for (const g of jwtCfg.public) {
102
+ if (typeof g === 'string' && g.startsWith('/')) set.add(g);
103
+ }
104
+ }
105
+
106
+ // groups beats public: a page carrying both markers stays restricted.
107
+ // NOTE: this deletes exact slugs only — a public GLOB overlapping a group
108
+ // page (public: ['/admin/*'] + groups on /admin/keys) survives here and is
109
+ // neutralized at the edge instead (Task 7: group check runs before the
110
+ // public-path bypass). Both layers are required.
111
+ if (groupsPaths) {
112
+ for (const slug of Object.keys(groupsPaths)) set.delete(slug);
113
+ }
114
+
85
115
  return Array.from(set);
86
116
  }
87
117
 
118
+ // Exported so lib/jwt-session.ts applies the identical cap to token groups.
119
+ export const MAX_GROUPS_PER_PAGE = 32;
120
+ // Exported so lib/visibility.ts's hasGroupRestriction can apply the identical
121
+ // per-entry validity check — without this, a page with only invalid group
122
+ // entries (e.g. groups: [123] or a >64-char name) could be judged "restricted"
123
+ // by the build-artifact filter while the runtime edge gate (which uses this
124
+ // same MAX_GROUP_NAME_LEN via collectGroupsPaths below) judges it public,
125
+ // silently gating pages that are actually reachable — or vice versa.
126
+ export const MAX_GROUP_NAME_LEN = 64;
127
+
128
+ /**
129
+ * Map of page slug → required groups, from frontmatter `groups: [...]`.
130
+ * Invalid entries are dropped (build warning happens in build.ts, not here —
131
+ * this module stays pure). Empty arrays are omitted: "groups: []" means
132
+ * "no restriction", not "nobody".
133
+ */
134
+ export function collectGroupsPaths(input: Input): Record<string, string[]> {
135
+ const out: Record<string, string[]> = {};
136
+ // Slugs whose frontmatter explicitly declares a `groups` array — even one
137
+ // that ends up empty after filtering (e.g. `groups: []`, or every entry
138
+ // junk). Tracked separately from `out` so an explicit opt-out is protected
139
+ // from locale inheritance below, not just indistinguishable from "no key".
140
+ const ownGroups = new Set<string>();
141
+ for (const p of input.pagesWithFrontmatter) {
142
+ const raw = p.frontmatter.groups;
143
+ if (!Array.isArray(raw)) continue;
144
+ ownGroups.add(p.slug);
145
+ const groups = raw
146
+ .filter((g): g is string => typeof g === 'string' && g.length > 0 && g.length <= MAX_GROUP_NAME_LEN)
147
+ .slice(0, MAX_GROUPS_PER_PAGE);
148
+ if (groups.length > 0) out[p.slug] = groups;
149
+ }
150
+
151
+ // Locale inheritance: a translated page (/fr/admin) whose frontmatter lacks
152
+ // `groups` inherits the base page's restriction. Translations routinely drop
153
+ // frontmatter fields; without this, every localized variant of a restricted
154
+ // page silently fails OPEN to all authenticated users. Own groups always
155
+ // win — including an explicit `groups: []` opt-out, which must NOT be
156
+ // overwritten by inheritance just because it's absent from `out`.
157
+ //
158
+ // The locale set is the configured `navigation.languages` UNIONED with every
159
+ // on-disk top-level folder that is a known language code. The build globs
160
+ // and serves every locale directory regardless of docs.json (a language
161
+ // removed from the config, or a hand-translated tree, still renders), and
162
+ // the edge gate is an exact-key lookup — so a locale the config doesn't
163
+ // list would otherwise fail OPEN to every authenticated session.
164
+ const slugs = new Set(input.pagesWithFrontmatter.map((p) => p.slug));
165
+ const localeSet = new Set<string>(
166
+ (input.docsConfig.navigation?.languages ?? [])
167
+ .map((l) => (l as { language?: unknown }).language)
168
+ .filter((c): c is string => typeof c === 'string'),
169
+ );
170
+ for (const slug of slugs) {
171
+ // '/fr/guide' → ['', 'fr', 'guide']: a locale dir only counts when
172
+ // something lives under it (a top-level '/fr' page is just a page).
173
+ const [, first, ...rest] = slug.split('/');
174
+ if (first && rest.length > 0 && isKnownLanguageCode(first)) {
175
+ localeSet.add(first);
176
+ }
177
+ }
178
+ const locales = Array.from(localeSet);
179
+ if (locales.length > 0) {
180
+ for (const [base, groups] of Object.entries(out)) {
181
+ for (const locale of locales) {
182
+ const localized = `/${locale}${base}`;
183
+ if (slugs.has(localized) && !ownGroups.has(localized)) out[localized] = groups;
184
+ }
185
+ }
186
+ }
187
+
188
+ // Root-path aliases: the bare root renders the FIRST NAV PAGE in place — `/`
189
+ // (and hostAtDocs `/docs`) → findFirstNavPage(nav); a locale root `/fr` →
190
+ // that language block's first page (see render-doc-page.tsx resolveSlug/
191
+ // findFirstPage). The edge gate looks up `/` (or `/fr`) directly, so a
192
+ // group-restricted homepage would fail OPEN at the root without this alias —
193
+ // even though its own URL (/introduction) stays gated. Mirrors detectAuthMode's
194
+ // existing `/`↔firstPage public-path precedent. Only aliased when the resolved
195
+ // first page is actually restricted (its groups landed in `out`). Own entries
196
+ // win: never clobber an explicit `/`/`/fr` page key set above.
197
+ const nav = input.docsConfig.navigation as
198
+ | { languages?: Array<{ language?: unknown }> }
199
+ | undefined;
200
+ const aliasFirstPage = (rootKey: string, firstPage: string | null): void => {
201
+ if (firstPage && out[firstPage] && !ownGroups.has(rootKey)) {
202
+ out[rootKey] = out[firstPage];
203
+ }
204
+ };
205
+ aliasFirstPage('/', findFirstNavPage(input.docsConfig.navigation));
206
+ if (locales.length > 0) {
207
+ for (const locale of locales) {
208
+ const langBlock = nav?.languages?.find(
209
+ (l) => (l as { language?: unknown }).language === locale,
210
+ );
211
+ const localeFirst =
212
+ (langBlock && findFirstNavPage(langBlock)) ||
213
+ findFirstNavPage(input.docsConfig.navigation);
214
+ aliasFirstPage(`/${locale}`, localeFirst);
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+
88
220
  /**
89
221
  * Collect paths marked private via:
90
222
  * 1. MDX frontmatter `private: true`
@@ -133,31 +265,54 @@ export type AuthMode = 'off' | 'all' | 'specific';
133
265
 
134
266
  export interface AuthModeResult {
135
267
  mode: AuthMode;
268
+ authType: 'password' | 'jwt';
136
269
  publicPaths: string[];
137
270
  privatePaths: string[];
271
+ groupsPaths: Record<string, string[]>;
138
272
  }
139
273
 
140
274
  /**
141
275
  * Determine the effective auth mode from the docs config and page frontmatter.
142
276
  *
143
- * 1. `auth.password.enabled === true` → mode='all'
144
- * 2. Private markers exist (frontmatter or config) → mode='specific'
145
- * 3. Otherwise → mode='off'
277
+ * 1. `auth.password.enabled === true` → mode='all', authType='password'
278
+ * 2. `auth.jwt.enabled === true` → mode='all', authType='jwt' (only reached
279
+ * when password is not enabled — validation blocks both being enabled at
280
+ * once, but if a stale config ever reaches here, password wins
281
+ * deterministically).
282
+ * 3. Private markers exist (frontmatter or config) → mode='specific'
283
+ * 4. Otherwise → mode='off'
146
284
  */
147
285
  export function detectAuthMode(input: Input): AuthModeResult {
148
- const enabled = input.docsConfig.auth?.password?.enabled === true;
286
+ const passwordEnabled = input.docsConfig.auth?.password?.enabled === true;
287
+ const jwtEnabled = input.docsConfig.auth?.jwt?.enabled === true;
149
288
 
150
- if (enabled) {
289
+ if (passwordEnabled) {
151
290
  return {
152
291
  mode: 'all',
292
+ authType: 'password',
153
293
  publicPaths: resolvePublicPaths(input),
154
294
  privatePaths: [],
295
+ groupsPaths: {},
296
+ };
297
+ }
298
+
299
+ if (jwtEnabled) {
300
+ // Computed once and threaded into the public-path resolution — it is the
301
+ // only mode where groups are enforced (and the only mode that subtracts
302
+ // group pages from the public set).
303
+ const groupsPaths = collectGroupsPaths(input);
304
+ return {
305
+ mode: 'all',
306
+ authType: 'jwt',
307
+ publicPaths: resolvePublicPaths(input, groupsPaths),
308
+ privatePaths: [],
309
+ groupsPaths,
155
310
  };
156
311
  }
157
312
 
158
313
  const privatePaths = collectPrivatePaths(input);
159
314
  if (privatePaths.length === 0) {
160
- return { mode: 'off', publicPaths: [], privatePaths: [] };
315
+ return { mode: 'off', authType: 'password', publicPaths: [], privatePaths: [], groupsPaths: {} };
161
316
  }
162
317
 
163
318
  // Specific mode: invert the page list (all pages minus private) and
@@ -183,7 +338,9 @@ export function detectAuthMode(input: Input): AuthModeResult {
183
338
 
184
339
  return {
185
340
  mode: 'specific',
341
+ authType: 'password',
186
342
  publicPaths: Array.from(publicSet),
187
343
  privatePaths,
344
+ groupsPaths: {},
188
345
  };
189
346
  }
@@ -5,6 +5,10 @@ interface AuthSecret {
5
5
  hash?: unknown;
6
6
  }
7
7
 
8
+ interface JwtSecret {
9
+ publicKeyPem?: unknown;
10
+ }
11
+
8
12
  interface AuthPublic {
9
13
  enabled?: unknown;
10
14
  }
@@ -17,8 +21,23 @@ function overrideEnabled(): boolean {
17
21
  return process.env.R2_ALLOW_GATED_PUBLIC_BUCKET?.trim() === 'true';
18
22
  }
19
23
 
20
- function isGated(secret: AuthSecret | null, pub: AuthPublic | null): boolean {
21
- return typeof secret?.hash === 'string' && pub?.enabled === true;
24
+ // Considers EITHER secret key — a JWT-gated project must also be treated
25
+ // as gated here, or its content gets written to public/versioned R2 keys
26
+ // (defeating the guard's purpose). Deliberately a SUPERSET of
27
+ // isProjectGated's mode-bound truth table (which consults only the key for
28
+ // the configured authType): a lingering hash on a jwt tenant with no jwt
29
+ // key makes this guard write gated keys while the reader takes the public
30
+ // path and falls back to the S3 SDK — a latency cost, never an exposure.
31
+ // Fail-closed is the right bias for a write-side guard.
32
+ function isGated(
33
+ secret: AuthSecret | null,
34
+ pub: AuthPublic | null,
35
+ jwtSecret: JwtSecret | null,
36
+ ): boolean {
37
+ return (
38
+ pub?.enabled === true &&
39
+ (typeof secret?.hash === 'string' || typeof jwtSecret?.publicKeyPem === 'string')
40
+ );
22
41
  }
23
42
 
24
43
  /**
@@ -83,16 +102,19 @@ export async function resolveR2WriteScheme(
83
102
  // public bucket unverified.
84
103
  let secret: AuthSecret | null = null;
85
104
  let pub: AuthPublic | null = null;
105
+ let jwtSecret: JwtSecret | null = null;
86
106
  let lastErr: unknown;
87
107
  let read = false;
88
108
  for (let attempt = 1; attempt <= retryAttempts; attempt++) {
89
109
  try {
90
- const [secretResult, publicResult] = await Promise.all([
110
+ const [secretResult, publicResult, jwtSecretResult] = await Promise.all([
91
111
  client.get(`projectAuthSecret:${slug}`),
92
112
  client.get(`projectAuthPublic:${slug}`),
113
+ client.get(`projectJwtSecret:${slug}`),
93
114
  ]);
94
115
  secret = secretResult as AuthSecret | null;
95
116
  pub = publicResult as AuthPublic | null;
117
+ jwtSecret = jwtSecretResult as JwtSecret | null;
96
118
  read = true;
97
119
  break;
98
120
  } catch (err) {
@@ -109,5 +131,5 @@ export async function resolveR2WriteScheme(
109
131
  );
110
132
  }
111
133
 
112
- return isGated(secret, pub) ? 'legacy-only' : 'versioned+legacy';
134
+ return isGated(secret, pub, jwtSecret) ? 'legacy-only' : 'versioned+legacy';
113
135
  }
@@ -56,6 +56,14 @@ async function upstashCommand(
56
56
  return data?.result;
57
57
  }
58
58
 
59
+ export interface ProjectAuthPublicValue {
60
+ enabled: boolean;
61
+ publicPaths: unknown[];
62
+ authType?: 'password' | 'jwt';
63
+ loginUrl?: string;
64
+ groupsPaths?: Record<string, string[]>;
65
+ }
66
+
59
67
  /**
60
68
  * Public part of project auth: the docs.json enabled flag + the resolved list
61
69
  * of paths that bypass the password check (from frontmatter `public: true`,
@@ -64,10 +72,14 @@ async function upstashCommand(
64
72
  *
65
73
  * The `enabled` flag is the docs.json truth-table source. Middleware only
66
74
  * gates traffic when enabled=true AND a hash exists in projectAuthSecret.
75
+ *
76
+ * `authType`/`loginUrl`/`groupsPaths` are JWT-mode fields (Task 3). They are
77
+ * only serialized when present so password payloads keep the exact legacy
78
+ * shape byte-for-byte — old readers (and their tests) see no change.
67
79
  */
68
80
  export async function setProjectAuthPublic(
69
81
  slug: string,
70
- value: { enabled: boolean; publicPaths: unknown[] },
82
+ value: ProjectAuthPublicValue,
71
83
  kvUrl?: string,
72
84
  kvToken?: string
73
85
  ): Promise<void> {
@@ -79,12 +91,18 @@ export async function setProjectAuthPublic(
79
91
  const publicPaths = value.publicPaths.filter(
80
92
  (p): p is string => typeof p === 'string',
81
93
  );
94
+ const payload: Record<string, unknown> = { enabled: value.enabled, publicPaths };
95
+ if (value.authType) payload.authType = value.authType;
96
+ if (value.loginUrl) payload.loginUrl = value.loginUrl;
97
+ if (value.groupsPaths && Object.keys(value.groupsPaths).length > 0) {
98
+ payload.groupsPaths = value.groupsPaths;
99
+ }
82
100
  await upstashCommand(
83
101
  kvUrl,
84
102
  kvToken,
85
103
  'SET',
86
104
  `projectAuthPublic:${slug}`,
87
- JSON.stringify({ enabled: value.enabled, publicPaths }),
105
+ JSON.stringify(payload),
88
106
  );
89
107
  }
90
108