jamdesk 1.1.204 → 1.1.205
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/unit/deploy.test.js +106 -0
- package/dist/__tests__/unit/deploy.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/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/commands/deploy.js +16 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +5 -0
- 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/package.json +1 -1
- 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/layout.tsx +7 -0
- 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/mdx-inline-components.ts +1 -1
- package/vendored/lib/middleware-helpers.ts +11 -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 +74 -13
- package/vendored/lib/root-page-slug.ts +20 -1
- 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 +73 -63
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -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
|
|