jamdesk 1.1.203 → 1.1.205
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -1
- package/dist/__tests__/integration/init.integration.test.js.map +1 -1
- package/dist/__tests__/integration/prepublish.integration.test.js.map +1 -1
- package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
- package/dist/__tests__/unit/banner-art-sync.test.js.map +1 -1
- package/dist/__tests__/unit/deploy-templates.test.js.map +1 -1
- package/dist/__tests__/unit/deploy.test.js +106 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -1
- package/dist/__tests__/unit/deprecated-components-sync.test.js.map +1 -1
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -1
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -1
- package/dist/__tests__/unit/frontmatter-sync.test.js.map +1 -1
- package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -1
- package/dist/__tests__/unit/inert-config-keys-sync.test.js.map +1 -1
- package/dist/__tests__/unit/init.test.js.map +1 -1
- package/dist/__tests__/unit/install-banner-gating.test.js.map +1 -1
- package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts +22 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts.map +1 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.js +193 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.js.map +1 -0
- package/dist/__tests__/unit/openapi-schema-patch-sync.test.js.map +1 -1
- package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js +1 -1
- package/dist/__tests__/unit/openapi.test.js.map +1 -1
- package/dist/__tests__/unit/package-config.test.js.map +1 -1
- package/dist/__tests__/unit/project-favicon.test.js.map +1 -1
- package/dist/__tests__/unit/prompt-vendoring.test.js.map +1 -1
- package/dist/__tests__/unit/risky-expression-scanner-sync.test.js.map +1 -1
- package/dist/__tests__/unit/seo-ai-schema.test.js.map +1 -1
- package/dist/__tests__/unit/snippet-scanner-sync.test.js.map +1 -1
- package/dist/__tests__/unit/spec-generation-notice.test.js.map +1 -1
- package/dist/__tests__/unit/templates-consistency.test.js.map +1 -1
- package/dist/__tests__/unit/validate-inert-config-keys.test.js.map +1 -1
- package/dist/__tests__/unit/validate-jwt-auth.test.d.ts +2 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.d.ts.map +1 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.js +77 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.js.map +1 -0
- package/dist/__tests__/unit/validate-password-warning.test.js.map +1 -1
- package/dist/__tests__/unit/vendored-sync.test.js +11 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/commands/broken-links.js.map +1 -1
- package/dist/commands/clean.js.map +1 -1
- package/dist/commands/deploy/cloudflare.js.map +1 -1
- package/dist/commands/deploy.js +16 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/fix.d.ts.map +1 -1
- package/dist/commands/fix.js.map +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/deps.d.ts +4 -4
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +15 -10
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +49 -0
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/find-project-dir.js.map +1 -1
- package/dist/lib/first-run-banner.js.map +1 -1
- package/dist/lib/mdx-validator.js.map +1 -1
- package/dist/lib/missing-description-scan.js.map +1 -1
- package/dist/utils/update-checker.js.map +1 -1
- package/package.json +9 -8
- package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +1 -7
- package/vendored/app/(jd-system)/jd-inactive/page.tsx +2 -4
- package/vendored/app/(unlock)/jd/auth/callback/CallbackClient.tsx +33 -0
- package/vendored/app/(unlock)/jd/auth/callback/page.tsx +15 -0
- package/vendored/app/(unlock)/jd/unlock/page.tsx +29 -7
- package/vendored/app/api/jd/auth/callback/route.ts +103 -0
- package/vendored/app/api/jd/auth/logout/route.ts +38 -0
- package/vendored/app/api/jd/auth/me/route.ts +52 -0
- package/vendored/app/api/jd/unlock/route.ts +26 -2
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +65 -22
- package/vendored/app/api/raw-content/[...slug]/route.ts +21 -1
- package/vendored/app/layout.tsx +7 -0
- package/vendored/components/AIActionsMenu.tsx +19 -2
- package/vendored/components/mdx/OpenApiEndpoint.tsx +4 -0
- package/vendored/components/navigation/Header.tsx +5 -0
- package/vendored/components/navigation/LogoutButton.tsx +41 -0
- package/vendored/lib/auth-resolver.ts +125 -50
- package/vendored/lib/cached-redis.ts +28 -3
- package/vendored/lib/docs-types.ts +17 -1
- package/vendored/lib/glob-match.ts +54 -0
- package/vendored/lib/jwt-auth-config.ts +37 -0
- package/vendored/lib/jwt-session.ts +98 -0
- package/vendored/lib/language-utils.ts +14 -2
- package/vendored/lib/layout-helpers.tsx +4 -0
- package/vendored/lib/mdx-inline-components.ts +1 -1
- package/vendored/lib/middleware-helpers.ts +27 -6
- package/vendored/lib/nav-group-filter.ts +81 -0
- package/vendored/lib/public-paths-resolver.ts +164 -7
- package/vendored/lib/r2-public-storage-guard.ts +26 -4
- package/vendored/lib/redis.ts +20 -2
- package/vendored/lib/render-doc-page.tsx +76 -34
- package/vendored/lib/root-page-slug.ts +110 -0
- package/vendored/lib/same-origin.ts +38 -0
- package/vendored/lib/sanitize-from.ts +31 -1
- package/vendored/lib/session-hint.ts +11 -0
- package/vendored/lib/snippet-compiler-isr.ts +1 -1
- package/vendored/lib/snippet-loader-isr.ts +1 -1
- package/vendored/lib/validate-config.ts +14 -0
- package/vendored/lib/viewer-navigation.ts +45 -0
- package/vendored/lib/visibility.ts +65 -0
- package/vendored/schema/docs-schema.json +25 -0
- package/vendored/shared/auth-cookie.ts +98 -5
- package/vendored/workspace-package-lock.json +474 -183
|
@@ -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
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
log('info', 'Project resolved from custom domain', { hostname, projectSlug: resolution.projectSlug
|
|
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
|
-
|
|
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.
|
|
145
|
-
*
|
|
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
|
|
286
|
+
const passwordEnabled = input.docsConfig.auth?.password?.enabled === true;
|
|
287
|
+
const jwtEnabled = input.docsConfig.auth?.jwt?.enabled === true;
|
|
149
288
|
|
|
150
|
-
if (
|
|
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
|
-
|
|
21
|
-
|
|
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
|
}
|
package/vendored/lib/redis.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
105
|
+
JSON.stringify(payload),
|
|
88
106
|
);
|
|
89
107
|
}
|
|
90
108
|
|