jamdesk 1.1.222 → 1.1.223

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.222",
3
+ "version": "1.1.223",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -13,7 +13,9 @@
13
13
  * a cross-site POST would store an attacker-chosen session, groups and
14
14
  * playground inputs included, in the victim's cookie jar).
15
15
  * 404 unknown-project / not-enabled — no slug header, or project isn't in
16
- * jwt mode (mirrors resolveAuth's null-on-off-or-wrong-mode contract).
16
+ * jwt mode (resolveAuth null, or a password-mode auth).
17
+ * 401 no-signing-key — jwt mode is on but no public key is stored
18
+ * (resolveAuth `locked`): the site stays gated and no token can verify.
17
19
  * 400 bad-request — malformed JSON or missing/oversized jwt field.
18
20
  * 401 <reason> — verifyDocsJwt rejected the token (invalid signature,
19
21
  * expired, host mismatch, etc.) — reason echoed from jwt-session.
@@ -60,9 +62,12 @@ export async function POST(req: Request): Promise<NextResponse> {
60
62
  if (e instanceof AuthResolutionError) return NextResponse.json({ ok: false, error: 'unavailable' }, { status: 503 });
61
63
  throw e;
62
64
  }
63
- if (!auth || auth.authType !== 'jwt' || !auth.publicKeyPem) {
65
+ if (!auth || auth.authType !== 'jwt') {
64
66
  return NextResponse.json({ ok: false, error: 'not-enabled' }, { status: 404 });
65
67
  }
68
+ if (auth.locked || !auth.publicKeyPem) {
69
+ return NextResponse.json({ ok: false, error: 'no-signing-key' }, { status: 401 });
70
+ }
66
71
 
67
72
  const host = getRequestHost(req);
68
73
  const outcome = await verifyDocsJwt(body.jwt, auth.publicKeyPem, host);
@@ -54,7 +54,9 @@ export async function GET(req: Request): Promise<Response> {
54
54
  if (slug) {
55
55
  try {
56
56
  const auth = await resolveAuth(slug);
57
- if (auth?.authType === 'jwt' && auth.loginUrl) location = auth.loginUrl;
57
+ // A locked site has no working login (no signing key): send visitors
58
+ // to '/', where the gate shows the explainer, like it does for pages.
59
+ if (auth?.authType === 'jwt' && !auth.locked && auth.loginUrl) location = auth.loginUrl;
58
60
  } catch { /* logout must always succeed; fall back to '/' */ }
59
61
  }
60
62
  const headers = new Headers({ Location: location, 'Cache-Control': 'no-store' });
@@ -33,7 +33,8 @@ export async function GET(req: Request): Promise<NextResponse> {
33
33
 
34
34
  let auth;
35
35
  try { auth = await resolveAuth(slug); } catch { return NextResponse.json(UNAUTHENTICATED, noStore); }
36
- if (!auth || auth.authType !== 'jwt') return NextResponse.json(UNAUTHENTICATED, noStore);
36
+ // A locked jwt site (no signing key) has no session anyone can hold.
37
+ if (!auth || auth.authType !== 'jwt' || auth.locked) return NextResponse.json(UNAUTHENTICATED, noStore);
37
38
 
38
39
  const cookieHeader = req.headers.get('cookie') || '';
39
40
  const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)jd_auth_${slug}=([^;]+)`));
@@ -7,8 +7,11 @@
7
7
  * Flow:
8
8
  * 1. Validate inputs (slug, password fields)
9
9
  * 2. Check rate limits (per-IP + per-slug backstop) — BEFORE scrypt
10
- * 3. Fetch stored hash from Redis
11
- * 4. Always run scrypt (DUMMY_HASH when no hash stored) to prevent timing oracles
10
+ * 3. Resolve the gate via resolveAuth (the edge gate's own function), so a
11
+ * cookie is minted only for a site the gate treats as an ACTIVE
12
+ * password gate: not for an off/missing record, a jwt site, or a LOCKED
13
+ * site with no usable hash
14
+ * 4. Always run scrypt (DUMMY_HASH when there is no hash) to prevent timing oracles
12
15
  * 5. On match: sign host-bound cookie, 303 to sanitized `from`
13
16
  * 6. On mismatch: 303 to unlock page with error=1
14
17
  */
@@ -19,7 +22,7 @@ export const dynamic = 'force-dynamic';
19
22
  import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
20
23
  import { promisify } from 'util';
21
24
  import { redis } from '@/lib/redis';
22
- import { getProjectAuthSecret, getProjectAuthPublic } from '@/lib/cached-redis';
25
+ import { resolveAuth, type ResolvedAuth } from '@/lib/auth-resolver';
23
26
  import { sanitizeFrom } from '@/lib/sanitize-from';
24
27
  import { VALID_SLUG_RE } from '@/lib/middleware-helpers';
25
28
  import { signAuthCookie } from '@/shared/auth-cookie';
@@ -220,13 +223,6 @@ interface StoredSecret {
220
223
  version: number;
221
224
  }
222
225
 
223
- // Returns null only when Redis is unconfigured (local dev). Real errors
224
- // propagate so POST can fail closed with 503 — matching resolveAuth.
225
- async function fetchStoredSecret(slug: string): Promise<StoredSecret | null> {
226
- if (!redis) return null;
227
- return getProjectAuthSecret(slug);
228
- }
229
-
230
226
  export async function POST(req: Request): Promise<Response> {
231
227
  // Login-CSRF guard: a cross-site form POST would store a session in the
232
228
  // victim's jar. Checked first — before scrypt, before rate limiting — so a
@@ -297,15 +293,9 @@ export async function POST(req: Request): Promise<Response> {
297
293
  });
298
294
  }
299
295
 
300
- let stored: StoredSecret | null;
301
- let authType: 'password' | 'jwt' | undefined;
296
+ let auth: ResolvedAuth | null;
302
297
  try {
303
- const [storedResult, pubResult] = await Promise.all([
304
- fetchStoredSecret(slug),
305
- getProjectAuthPublic(slug),
306
- ]);
307
- stored = storedResult;
308
- authType = pubResult?.authType;
298
+ auth = await resolveAuth(slug);
309
299
  } catch (err) {
310
300
  console.error('[unlock] Redis fetch failed — failing closed', {
311
301
  slug,
@@ -322,10 +312,22 @@ export async function POST(req: Request): Promise<Response> {
322
312
  // unlock form verify and mint a v1 cookie the jwt gate rejects — not a
323
313
  // bypass (the jwt gate never accepts a v1 cookie), but a confusing dead
324
314
  // end for the user. Fail fast instead.
325
- if (authType === 'jwt') {
315
+ if (auth?.authType === 'jwt') {
326
316
  return new Response('not-enabled', { status: 404 });
327
317
  }
328
318
 
319
+ // Only an ACTIVE password gate has a hash to verify against. null (local
320
+ // dev, protection off, never built) and LOCKED (enabled, no usable hash —
321
+ // the shape rule is isUsableAuthSecret, shared with the edge) both fall
322
+ // through to DUMMY_HASH with `stored` null, which can never mint: the
323
+ // DUMMY_HASH plaintext is public (this route ships in the npm CLI), so the
324
+ // `!stored` checks below are what make it safe, and the master password
325
+ // needs `stored` too.
326
+ const stored: StoredSecret | null =
327
+ auth && !auth.locked && typeof auth.hash === 'string'
328
+ ? { hash: auth.hash, version: auth.version }
329
+ : null;
330
+
329
331
  const hashToVerify = stored?.hash ?? DUMMY_HASH;
330
332
 
331
333
  // Always run scrypt — even with DUMMY_HASH when no password is set — to
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The auth plane's shape rules, in one Redis-free place.
3
+ *
4
+ * Every component that decides "is this site gated?" or "can this stored
5
+ * secret unlock it?" must give the same answer, or one of them opens a site
6
+ * the others treat as closed. Consumers: resolveAuth and isProjectGated
7
+ * (auth-resolver.ts), the build's R2 write guard (r2-public-storage-guard.ts),
8
+ * and through resolveAuth the unlock route.
9
+ *
10
+ * scripts/lib/public-storage-guard.cjs (the jamdesk.cjs upload path) cannot
11
+ * import a TS module, so it carries a copy of isGatedPublicRecord; the drift
12
+ * test in __tests__/scripts/lib/public-storage-guard.test.ts pins the two to
13
+ * the same truth table.
14
+ */
15
+
16
+ /**
17
+ * `projectAuthPublic.enabled === true` is gated, whether or not the mode's
18
+ * secret exists. A missing secret makes the gate LOCKED, never open. Strict
19
+ * `=== true`: the build writes a boolean, and 'true' or 1 are not gates.
20
+ */
21
+ export function isGatedPublicRecord(pub: unknown): boolean {
22
+ return (pub as { enabled?: unknown } | null | undefined)?.enabled === true;
23
+ }
24
+
25
+ /** A password secret that can verify a password: string hash, numeric version. */
26
+ export function isUsableAuthSecret(
27
+ value: unknown,
28
+ ): value is { hash: string; version: number } {
29
+ const v = value as { hash?: unknown; version?: unknown } | null | undefined;
30
+ return typeof v?.hash === 'string' && typeof v.version === 'number';
31
+ }
32
+
33
+ /** A jwt secret that can verify a token: non-empty PEM, numeric version. */
34
+ export function isUsableJwtSecret(
35
+ value: unknown,
36
+ ): value is { publicKeyPem: string; version: number } {
37
+ const v = value as { publicKeyPem?: unknown; version?: unknown } | null | undefined;
38
+ return (
39
+ typeof v?.publicKeyPem === 'string' &&
40
+ v.publicKeyPem.length > 0 &&
41
+ typeof v.version === 'number'
42
+ );
43
+ }
@@ -1,29 +1,37 @@
1
1
  import { redis } from './redis';
2
2
  import { getProjectAuthSecret, getProjectAuthPublic, getProjectJwtSecret } from './cached-redis';
3
3
  import { matchPublicPath, resolveRequiredGroups } from './glob-match';
4
+ import { isGatedPublicRecord, isUsableAuthSecret, isUsableJwtSecret } from './auth-gate-rules';
4
5
 
5
6
  export { matchPublicPath, resolveRequiredGroups };
6
7
 
7
- export interface ResolvedAuth {
8
+ interface ResolvedAuthBase {
8
9
  authType: 'password' | 'jwt';
9
- hash?: string;
10
- publicKeyPem?: string;
11
- version: number;
12
10
  publicPaths: string[];
13
11
  loginUrl?: string;
14
12
  groupsPaths?: Record<string, string[]>;
15
13
  }
16
14
 
17
- interface AuthSecret {
18
- hash: string;
15
+ /** Gate with a usable secret: a cookie bound to `version` can pass. */
16
+ export interface ActiveAuth extends ResolvedAuthBase {
17
+ locked?: undefined;
18
+ hash?: string;
19
+ publicKeyPem?: string;
19
20
  version: number;
20
21
  }
21
22
 
22
- interface JwtSecret {
23
- publicKeyPem: string;
24
- version: number;
23
+ /**
24
+ * Gate with NO usable secret (protection on, password not set / signing key
25
+ * cleared / malformed secret). Nothing can unlock it. It deliberately has no
26
+ * `version`, `hash` or `publicKeyPem`, so a consumer that verifies a cookie or
27
+ * token must narrow on `locked` first or it won't compile.
28
+ */
29
+ export interface LockedAuth extends ResolvedAuthBase {
30
+ locked: true;
25
31
  }
26
32
 
33
+ export type ResolvedAuth = ActiveAuth | LockedAuth;
34
+
27
35
  interface AuthPublic {
28
36
  enabled?: boolean;
29
37
  publicPaths?: string[];
@@ -35,13 +43,29 @@ interface AuthPublic {
35
43
  /**
36
44
  * Resolve active auth protection (password OR jwt) for a project.
37
45
  *
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:
46
+ * Truth table — `enabled === true` ALWAYS gates; the config plane's declared
47
+ * `authType` alone picks the mode, and only that mode's secret can unlock it:
40
48
  * projectAuthPublic missing OR enabled!==true → no gate (return null)
41
49
  * authType==='jwt' AND projectJwtSecret has a publicKeyPem → jwt gate
42
- * authType==='jwt' AND projectJwtSecret missing/malformed → no gate (return null)
50
+ * authType==='jwt' AND projectJwtSecret missing/malformed → LOCKED jwt gate
43
51
  * authType!=='jwt' (password, incl. absent) AND projectAuthSecret has a hash → password gate
44
- * authType!=='jwt' AND projectAuthSecret missing/malformed → no gate (return null)
52
+ * authType!=='jwt' AND projectAuthSecret missing/malformed → LOCKED password gate
53
+ *
54
+ * LOCKED (`locked: true`) is the state between the build turning protection
55
+ * on and the owner setting a password or generating a key, or after "clear
56
+ * password" / "Clear signing key". The docs promise every request is rejected
57
+ * there, so it must never fall back to "no gate": a locked gate still honors
58
+ * publicPaths and answers like any unauthed request for its mode (unlock page,
59
+ * loginUrl redirect, plain 401), but no cookie, token or password passes it.
60
+ * This used to return null, which served a just-enabled site to the public.
61
+ *
62
+ * Project API keys (search, MCP) still work on a locked site, by design: they
63
+ * are a separate grant the owner created, checked before this gate's cookie
64
+ * logic. The owner revokes the key to stop that access.
65
+ *
66
+ * The shape rules (what counts as gated, what counts as a usable secret) live
67
+ * in auth-gate-rules.ts so the R2 write guard and the unlock route cannot
68
+ * drift from this function.
45
69
  *
46
70
  * Coexistence is by design: BOTH secret keys may exist simultaneously
47
71
  * during a password→JWT migration (the build writes the new key before
@@ -102,7 +126,7 @@ export async function resolveAuth(
102
126
  () => getProjectAuthPublic(projectSlug),
103
127
  );
104
128
 
105
- if (!pub || pub.enabled !== true) {
129
+ if (!pub || !isGatedPublicRecord(pub)) {
106
130
  return null;
107
131
  }
108
132
 
@@ -117,42 +141,37 @@ export async function resolveAuth(
117
141
  const authType: 'password' | 'jwt' = pub.authType === 'jwt' ? 'jwt' : 'password';
118
142
 
119
143
  if (authType === 'jwt') {
120
- const jwtSecret = await readOrFail<JwtSecret>(
144
+ const loginUrl = typeof pub.loginUrl === 'string' ? pub.loginUrl : undefined;
145
+ // Kept on a locked gate too: group pages must not be opened by an
146
+ // overlapping public glob just because the key is missing.
147
+ const groupsPaths =
148
+ pub.groupsPaths && typeof pub.groupsPaths === 'object' ? pub.groupsPaths : undefined;
149
+ const jwtSecret = await readOrFail<unknown>(
121
150
  () => getProjectJwtSecret(projectSlug),
122
151
  );
123
- if (
124
- !jwtSecret ||
125
- typeof jwtSecret.publicKeyPem !== 'string' ||
126
- jwtSecret.publicKeyPem.length === 0 ||
127
- typeof jwtSecret.version !== 'number'
128
- ) {
152
+ if (!isUsableJwtSecret(jwtSecret)) {
129
153
  // 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;
154
+ // malformed): locked, never open. A lingering password hash from a
155
+ // prior mode must NEVER satisfy jwt mode either.
156
+ return { authType, locked: true, publicPaths, loginUrl, groupsPaths };
133
157
  }
134
158
  return {
135
159
  authType,
136
160
  publicKeyPem: jwtSecret.publicKeyPem,
137
161
  version: jwtSecret.version,
138
162
  publicPaths,
139
- loginUrl: typeof pub.loginUrl === 'string' ? pub.loginUrl : undefined,
140
- groupsPaths:
141
- pub.groupsPaths && typeof pub.groupsPaths === 'object'
142
- ? pub.groupsPaths
143
- : undefined,
163
+ loginUrl,
164
+ groupsPaths,
144
165
  };
145
166
  }
146
167
 
147
- const secret = await readOrFail<AuthSecret>(
168
+ const secret = await readOrFail<unknown>(
148
169
  () => getProjectAuthSecret(projectSlug),
149
170
  );
150
- if (
151
- !secret ||
152
- typeof secret.hash !== 'string' ||
153
- typeof secret.version !== 'number'
154
- ) {
155
- return null;
171
+ if (!isUsableAuthSecret(secret)) {
172
+ // No password set yet (or "clear password", or a malformed record):
173
+ // locked. A jwt key that may still exist never satisfies password mode.
174
+ return { authType, locked: true, publicPaths };
156
175
  }
157
176
 
158
177
  return {
@@ -171,7 +190,7 @@ export async function resolveAuth(
171
190
  // like public-paths-resolver without dragging in the Redis client.
172
191
 
173
192
  /**
174
- * Quick boolean check: is this project password-gated right now?
193
+ * Quick boolean check: is this project gated (password or jwt) right now?
175
194
  * Used by R2 read paths to CHOOSE WHICH FETCH MECHANISM to use:
176
195
  * - true → S3 SDK (HMAC-authed, slower)
177
196
  * - false → CF public domain (fast, cached)
@@ -182,31 +201,22 @@ export async function resolveAuth(
182
201
  * fetch mechanism. Therefore: fail OPEN (return false) on Redis error so
183
202
  * brownouts don't nullify the CDN-cache latency win.
184
203
  *
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`.
204
+ * Reads go through cached-redis (getProjectAuthPublic), the same wrapper
205
+ * resolveAuth uses, so the two share one per-instance cache entry.
206
+ * cached-redis does NOT cache fetch errors, so the fail-open path below
207
+ * still retries on the next call rather than pinning `false`.
190
208
  *
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.
209
+ * Same definition as resolveAuth: `enabled === true` is gated, whether or not
210
+ * the mode's secret exists (a LOCKED gate is still a gate), so only the public
211
+ * record is read. r2-public-storage-guard uses the same rule on the write
212
+ * side: a gated site never gets versioned public keys, so the read side must
213
+ * pick the S3 path for exactly those sites or it would find nothing.
195
214
  */
196
215
  export async function isProjectGated(slug: string): Promise<boolean> {
197
216
  if (!redis) return false;
198
217
 
199
218
  try {
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';
219
+ return isGatedPublicRecord(await getProjectAuthPublic(slug));
210
220
  } catch {
211
221
  // Fail OPEN: this is a read-mechanism choice, not access control.
212
222
  // Gating already happened upstream in proxy.ts. Failing closed here
@@ -4,12 +4,13 @@ import type { BuildWarning } from '../shared/status-reporter.js'; // vendored co
4
4
  * Warn when a project declares JWT authentication but has no signing key.
5
5
  *
6
6
  * The truth table in auth-resolver.ts is explicit: `authType === 'jwt'` with a
7
- * missing or malformed `projectJwtSecret` means NO GATE. That is the correct
8
- * fail-safe for a key that was cleared deliberately, but it is also reachable
9
- * by accident: commit `auth.jwt.enabled: true`, publish, and never click
10
- * Generate signing key. The build then succeeds, the site serves every page to
11
- * the public, and nothing says so. The dashboard card does report it, but a
12
- * customer who publishes from git has no reason to open that page.
7
+ * missing or malformed `projectJwtSecret` is a LOCKED gate. Every visitor is
8
+ * turned away, because no token can be verified. That is the correct fail-safe
9
+ * for a key that was cleared deliberately, but it is also reachable by
10
+ * accident: commit `auth.jwt.enabled: true`, publish, and never click Generate
11
+ * signing key. The build then succeeds, nobody can read the site, and nothing
12
+ * says why. The dashboard card does report it, but a customer who publishes
13
+ * from git has no reason to open that page.
13
14
  *
14
15
  * Silent for `enabled: false`, where a public site is the stated intent, and
15
16
  * silent for password tenants, whose own secret has its own row in that table.
@@ -32,7 +33,7 @@ export function buildJwtKeyWarning(input: {
32
33
  // docs.json is where the opt-in lives and what an author edits to change it.
33
34
  file: 'docs.json',
34
35
  message:
35
- 'auth.jwt.enabled is true but this project has no signing key, so nothing is gated and every page is publicly readable. ' +
36
+ 'auth.jwt.enabled is true but this project has no signing key, so the site is locked: every visitor is turned away until a signing key is generated. ' +
36
37
  'Generate an Ed25519 signing key under Settings, or set auth.jwt.enabled to false if the site is meant to be public.',
37
38
  };
38
39
  }
@@ -1,17 +1,6 @@
1
1
  import { redis } from './redis.js';
2
2
  import { logger } from '../shared/logger.js';
3
-
4
- interface AuthSecret {
5
- hash?: unknown;
6
- }
7
-
8
- interface JwtSecret {
9
- publicKeyPem?: unknown;
10
- }
11
-
12
- interface AuthPublic {
13
- enabled?: unknown;
14
- }
3
+ import { isGatedPublicRecord } from './auth-gate-rules.js';
15
4
 
16
5
  function publicDomainConfigured(): boolean {
17
6
  return !!process.env.R2_PUBLIC_DOMAIN?.trim();
@@ -21,31 +10,12 @@ function overrideEnabled(): boolean {
21
10
  return process.env.R2_ALLOW_GATED_PUBLIC_BUCKET?.trim() === 'true';
22
11
  }
23
12
 
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
- );
41
- }
42
-
43
13
  /**
44
14
  * R2 write scheme for a build:
45
15
  * - 'versioned+legacy': dual-write as normal (the versioned/public keys the
46
16
  * CF custom domain serves are safe to write for this project).
47
- * - 'legacy-only': password-gated project — write ONLY the legacy
48
- * `{slug}/<file>` keys. The versioned public keys are never written, so
17
+ * - 'legacy-only': gated project (password or jwt, locked or not) — write
18
+ * ONLY the legacy `{slug}/<file>` keys. The versioned public keys are never written, so
49
19
  * gated content cannot enter the CF-served key space. The ISR reader keeps
50
20
  * serving these projects from the S3 SDK path (isProjectGated=true), so the
51
21
  * legacy keys are all it needs.
@@ -60,22 +30,36 @@ export type R2WriteScheme = 'versioned+legacy' | 'legacy-only';
60
30
  * mechanism after request auth has already happened. The write side is
61
31
  * different: before writing objects into a bucket that is exposed by
62
32
  * R2_PUBLIC_DOMAIN, it must fail closed unless we can prove the project is not
63
- * password-gated or an explicit customer-acceptance override is set.
33
+ * gated or an explicit customer-acceptance override is set.
64
34
  *
65
- * A password-gated project resolves to `'legacy-only'` — its versioned/public
35
+ * A gated project resolves to `'legacy-only'` — its versioned/public
66
36
  * keys are never written, so gated content stays out of the CF-served key
67
37
  * space (the build still succeeds; the S3 read path serves it). Non-gated
68
38
  * projects, the no-public-domain case, and the acceptance override all resolve
69
- * to `'versioned+legacy'`. Throws when gated status cannot be determined (Redis
39
+ * to `'versioned+legacy'`.
40
+ *
41
+ * Gated means `isGatedPublicRecord` (auth-gate-rules.ts): enabled === true,
42
+ * secret or not, the same rule as resolveAuth and isProjectGated. A site with
43
+ * protection on and no password/key yet is a LOCKED gate at the edge; writing
44
+ * its content to the public versioned keys would make it readable through the
45
+ * CDN path while the edge rejects every request.
46
+ *
47
+ * `declaredGated` is the build's own docs.json verdict. It wins over Redis:
48
+ * the build writes projectAuthPublic earlier, but that write is non-fatal, so
49
+ * on a failed write Redis still holds the previous (public) record and would
50
+ * send a newly gated site's content to the public keys. A declared gate is
51
+ * decided without a Redis read.
52
+ *
53
+ * Throws when gated status cannot be determined (Redis
70
54
  * unavailable / read fails after retries) — we must never write to a public
71
55
  * bucket unverified, and a Redis outage during upload is a real failure worth
72
56
  * surfacing (rare — the build has already done many Redis reads by this point).
73
57
  */
74
58
  export async function resolveR2WriteScheme(
75
59
  slug: string,
76
- opts: { retryAttempts?: number; retryDelayMs?: number } = {},
60
+ opts: { retryAttempts?: number; retryDelayMs?: number; declaredGated?: boolean } = {},
77
61
  ): Promise<R2WriteScheme> {
78
- const { retryAttempts = 3, retryDelayMs = 200 } = opts;
62
+ const { retryAttempts = 3, retryDelayMs = 200, declaredGated = false } = opts;
79
63
  if (!publicDomainConfigured()) return 'versioned+legacy';
80
64
  if (overrideEnabled()) {
81
65
  // The override must never run silently: it disables the only write-side
@@ -87,6 +71,7 @@ export async function resolveR2WriteScheme(
87
71
  );
88
72
  return 'versioned+legacy';
89
73
  }
74
+ if (declaredGated) return 'legacy-only';
90
75
 
91
76
  if (!redis) {
92
77
  throw new Error(
@@ -100,21 +85,12 @@ export async function resolveR2WriteScheme(
100
85
  // any read error) even for non-gated projects. An outage that outlasts the
101
86
  // retries still fails closed — correct, since we must never write to a
102
87
  // public bucket unverified.
103
- let secret: AuthSecret | null = null;
104
- let pub: AuthPublic | null = null;
105
- let jwtSecret: JwtSecret | null = null;
88
+ let pub: unknown = null;
106
89
  let lastErr: unknown;
107
90
  let read = false;
108
91
  for (let attempt = 1; attempt <= retryAttempts; attempt++) {
109
92
  try {
110
- const [secretResult, publicResult, jwtSecretResult] = await Promise.all([
111
- client.get(`projectAuthSecret:${slug}`),
112
- client.get(`projectAuthPublic:${slug}`),
113
- client.get(`projectJwtSecret:${slug}`),
114
- ]);
115
- secret = secretResult as AuthSecret | null;
116
- pub = publicResult as AuthPublic | null;
117
- jwtSecret = jwtSecretResult as JwtSecret | null;
93
+ pub = await client.get(`projectAuthPublic:${slug}`);
118
94
  read = true;
119
95
  break;
120
96
  } catch (err) {
@@ -131,5 +107,5 @@ export async function resolveR2WriteScheme(
131
107
  );
132
108
  }
133
109
 
134
- return isGated(secret, pub, jwtSecret) ? 'legacy-only' : 'versioned+legacy';
110
+ return isGatedPublicRecord(pub) ? 'legacy-only' : 'versioned+legacy';
135
111
  }
@@ -113,9 +113,9 @@ const CAPTURE_COOKIE_TTL_MS = 5 * 60_000;
113
113
  * nothing new. JWT mode gets no groups (least privilege): a group-restricted
114
114
  * homepage captures as the gate's 404.
115
115
  *
116
- * Returns null when the site is ungated, or when the cookie can't be minted —
117
- * the capture then proceeds unauthenticated, as it did before this existed.
118
- * Never rejects: the capture starts it before launching Chromium and may
116
+ * Returns null when the site is ungated or locked, or when the cookie can't be
117
+ * minted — the capture then proceeds unauthenticated, as it did before this
118
+ * existed. Never rejects: the capture starts it before launching Chromium and may
119
119
  * abandon it on timeout.
120
120
  */
121
121
  export async function mintCaptureAuthCookie(
@@ -130,6 +130,9 @@ export async function mintCaptureAuthCookie(
130
130
  return null;
131
131
  }
132
132
  if (!auth) return null;
133
+ // Locked (protection on, no password/key yet): the edge accepts no cookie,
134
+ // so capture what every visitor sees, the unlock page or login redirect.
135
+ if (auth.locked) return null;
133
136
  const secret = process.env.EDGE_AUTH_SECRET;
134
137
  if (!secret) {
135
138
  console.warn(`Screenshot: ${slug} is gated but EDGE_AUTH_SECRET is unset; capture will hit the password wall`);
@@ -25,6 +25,7 @@ import {
25
25
  guardPropertyKey,
26
26
  KEY_GUARD_NAME,
27
27
  STRICT_MODE_PROLOGUE,
28
+ MdxEvalGuardError,
28
29
  } from './mdx-eval-guard';
29
30
  import { extractUtilityCandidates, MAX_UTILITY_CANDIDATES } from './user-utility-css';
30
31
  import { logger } from '../shared/logger';
@@ -250,6 +251,125 @@ function extractExports(transpiledCode: string): Record<string, string> {
250
251
  return exports;
251
252
  }
252
253
 
254
+ /**
255
+ * Turn transpiled snippet code into ONE function body that declares everything
256
+ * at the file's top level and returns its named exports as an object.
257
+ *
258
+ * Why: per-export compile (extractExports) runs each export in its own
259
+ * function, so anything it references at the file's top level (a sibling
260
+ * `export const`, a non-exported helper) is "not defined" at render and the
261
+ * node silently drops. `jamdesk dev` imports the file as a real module, where
262
+ * that works, so the same snippet rendered locally and vanished in prod.
263
+ *
264
+ * The RETURNED names are exactly the ones extractExports finds (`export const`
265
+ * and plain `export function`). Other forms (`export let`, `export class`,
266
+ * `export { a as b }`, default exports) are still declared, so siblings can
267
+ * use them, but stay out of the result: a different export count would change
268
+ * which component loadSnippetsForIsr's single-export alias fallback picks for
269
+ * snippets that already work. `__proto__` is never returned, so no caller
270
+ * that copies exports by assignment can set a prototype through it.
271
+ *
272
+ * Runs on Babel OUTPUT, after mdxEvalGuardPlugin has already checked the
273
+ * author's code: it only removes `export` keywords and appends the return, so
274
+ * it adds no author-controlled code. Returns null for shapes it does not
275
+ * handle (re-exports from another module, destructured exports); the caller
276
+ * then uses the per-export path, exactly as before.
277
+ */
278
+ export function toSnippetModuleBody(transpiledCode: string): string | null {
279
+ const names: string[] = [];
280
+ let unsupported = false;
281
+
282
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
283
+ const stripExports = ({ types: t }: { types: any }) => ({
284
+ visitor: {
285
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
286
+ ExportNamedDeclaration(path: any) {
287
+ const { node } = path;
288
+ if (node.source) {
289
+ unsupported = true;
290
+ return;
291
+ }
292
+ const decl = node.declaration;
293
+ if (!decl) {
294
+ // `export { a as b }`: the locals are already declared.
295
+ path.remove();
296
+ return;
297
+ }
298
+ if (t.isVariableDeclaration(decl)) {
299
+ for (const d of decl.declarations) {
300
+ if (!t.isIdentifier(d.id)) {
301
+ unsupported = true;
302
+ return;
303
+ }
304
+ if (decl.kind === 'const') names.push(d.id.name);
305
+ }
306
+ } else if (t.isFunctionDeclaration(decl) && decl.id) {
307
+ if (!decl.async && !decl.generator) names.push(decl.id.name);
308
+ } else if (!(t.isClassDeclaration(decl) && decl.id)) {
309
+ unsupported = true;
310
+ return;
311
+ }
312
+ path.replaceWith(decl);
313
+ },
314
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
+ ExportDefaultDeclaration(path: any) {
316
+ const decl = path.node.declaration;
317
+ if ((t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id) {
318
+ path.replaceWith(decl);
319
+ } else {
320
+ path.remove();
321
+ }
322
+ },
323
+ ExportAllDeclaration() {
324
+ unsupported = true;
325
+ },
326
+ },
327
+ });
328
+
329
+ let code: string | null | undefined;
330
+ try {
331
+ code = transform(transpiledCode, {
332
+ plugins: [stripExports],
333
+ sourceType: 'module',
334
+ filename: 'snippet.js',
335
+ })?.code;
336
+ } catch {
337
+ return null;
338
+ }
339
+ const exported = names.filter((n) => n !== '__proto__');
340
+ if (unsupported || !code || exported.length === 0) return null;
341
+
342
+ // The block lets a top-level `const Note` shadow the built-in parameter of
343
+ // the same name instead of throwing "already declared".
344
+ return `{\n${code}\nreturn { ${exported.join(', ')} };\n}`;
345
+ }
346
+
347
+ /**
348
+ * Evaluate tenant code in a `new Function` with the snippet scope: React, the
349
+ * JSX runtime, the runtime key guard and the built-in components. The ONE
350
+ * place both snippet compile paths build that function, so the strict-mode
351
+ * prologue and the guard binding cannot drift apart between them.
352
+ */
353
+ function evaluateSnippetCode(
354
+ code: string,
355
+ builtInNames: string[],
356
+ builtInValues: AnyComponent[]
357
+ ): unknown {
358
+ // NOTE: new Function() is intentionally used here to compile user-provided snippets
359
+ const fn = new Function(
360
+ 'React',
361
+ '_jsx',
362
+ '_jsxs',
363
+ '_Fragment',
364
+ KEY_GUARD_NAME, // runtime half of mdxEvalGuardPlugin — see mdx-eval-guard.ts
365
+ ...builtInNames,
366
+ // STRICT_MODE_PROLOGUE is load-bearing, not hygiene: without it this
367
+ // body is sloppy-mode and `this` is globalThis. See mdx-eval-guard.ts.
368
+ `${STRICT_MODE_PROLOGUE}\n${code}`
369
+ );
370
+ return fn(React, jsxRuntime.jsx, jsxRuntime.jsxs, jsxRuntime.Fragment, guardPropertyKey, ...builtInValues);
371
+ }
372
+
253
373
  /**
254
374
  * Detect plain-markdown snippet source — Mintlify-style snippet files that
255
375
  * contain only markdown text (with optional inline JSX components) and have no
@@ -359,38 +479,74 @@ async function compileSnippet(
359
479
  // Transpile JSX to JavaScript
360
480
  const transpiled = transpileJsx(source);
361
481
 
362
- // Extract exports from transpiled code
363
- const exportedFunctions = extractExports(transpiled);
364
-
365
- // Compile each export into an actual React component
366
482
  const builtInNames = Object.keys(builtInComponents);
483
+ const builtInValues = builtInNames.map((n) => builtInComponents[n]);
484
+ const exports =
485
+ compileSnippetModule(transpiled, snippetPath, builtInNames, builtInValues) ??
486
+ compileSnippetPerExport(transpiled, snippetPath, builtInNames, builtInValues);
487
+
488
+ const result: CompiledSnippet = {
489
+ exports,
490
+ isClientComponent,
491
+ utilityCandidates: extractUtilityCandidates(source),
492
+ };
493
+
494
+ // Cache the result
495
+ snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
496
+
497
+ return result;
498
+ }
499
+
500
+ /**
501
+ * Compile the whole snippet file as one function body, so exports share the
502
+ * file's top-level scope like they do under `jamdesk dev`. Returns null (and
503
+ * the caller falls back to per-export compile) when the shape is unsupported
504
+ * or the top-level code throws on the server, e.g. browser-only code that
505
+ * never ran here before.
506
+ */
507
+ function compileSnippetModule(
508
+ transpiled: string,
509
+ snippetPath: string,
510
+ builtInNames: string[],
511
+ builtInValues: AnyComponent[]
512
+ ): Record<string, AnyComponent> | null {
513
+ const body = toSnippetModuleBody(transpiled);
514
+ if (!body) return null;
515
+ try {
516
+ return evaluateSnippetCode(body, builtInNames, builtInValues) as Record<string, AnyComponent>;
517
+ } catch (error) {
518
+ // An escape attempt caught by the runtime key guard must fail the snippet
519
+ // the way the static guard does, not slip through on the fallback path.
520
+ if (error instanceof MdxEvalGuardError) throw error;
521
+ logger.warn('[ISR Snippet] whole-file compile failed, using per-export compile', {
522
+ snippetPath,
523
+ error: error instanceof Error ? error.message : String(error),
524
+ });
525
+ return null;
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Pre-2026-10-12 path: each export compiled alone, so it cannot see the
531
+ * file's other top-level names. Kept as the fallback so a snippet that worked
532
+ * before keeps working when the whole-file compile can't be used.
533
+ */
534
+ function compileSnippetPerExport(
535
+ transpiled: string,
536
+ snippetPath: string,
537
+ builtInNames: string[],
538
+ builtInValues: AnyComponent[]
539
+ ): Record<string, AnyComponent> {
540
+ const exportedFunctions = extractExports(transpiled);
367
541
  const exports: Record<string, AnyComponent> = {};
368
542
 
369
543
  for (const [name, functionCode] of Object.entries(exportedFunctions)) {
370
544
  try {
371
- // Create function that returns the component
372
- // NOTE: new Function() is intentionally used here to compile user-provided snippets
373
-
374
- const createComponent = new Function(
375
- 'React',
376
- '_jsx',
377
- '_jsxs',
378
- '_Fragment',
379
- KEY_GUARD_NAME, // runtime half of mdxEvalGuardPlugin — see mdx-eval-guard.ts
380
- ...builtInNames,
381
- // STRICT_MODE_PROLOGUE is load-bearing, not hygiene: without it this
382
- // body is sloppy-mode and `this` is globalThis. See mdx-eval-guard.ts.
383
- `${STRICT_MODE_PROLOGUE}\nreturn ${functionCode}`
384
- );
385
-
386
- const component = createComponent(
387
- React,
388
- jsxRuntime.jsx,
389
- jsxRuntime.jsxs,
390
- jsxRuntime.Fragment,
391
- guardPropertyKey,
392
- ...builtInNames.map((n) => builtInComponents[n])
393
- );
545
+ const component = evaluateSnippetCode(
546
+ `return ${functionCode}`,
547
+ builtInNames,
548
+ builtInValues
549
+ ) as AnyComponent;
394
550
 
395
551
  exports[name] = component;
396
552
  } catch (error) {
@@ -402,16 +558,7 @@ async function compileSnippet(
402
558
  }
403
559
  }
404
560
 
405
- const result: CompiledSnippet = {
406
- exports,
407
- isClientComponent,
408
- utilityCandidates: extractUtilityCandidates(source),
409
- };
410
-
411
- // Cache the result
412
- snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
413
-
414
- return result;
561
+ return exports;
415
562
  }
416
563
 
417
564
  /**
@@ -34,7 +34,9 @@ export async function filterNavigationForViewer(
34
34
  const secret = process.env.EDGE_AUTH_SECRET;
35
35
  const cookieHeader = headersList.get('cookie') || '';
36
36
  const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)jd_auth_${slug}=([^;]+)`));
37
- if (secret && match) {
37
+ // Locked (jwt on, no signing key): no session can be valid, so the viewer
38
+ // has no groups and the nav over-hides like it does for anonymous viewers.
39
+ if (secret && match && !auth.locked) {
38
40
  const host = headersList.get('x-jamdesk-forwarded-host') || headersList.get('host') || '';
39
41
  try {
40
42
  const v = await verifyAuthCookie(match[1], { slug, host, version: auth.version }, secret);
@@ -39,6 +39,50 @@ async function writeIfChanged(filePath, content) {
39
39
  return true; // Written
40
40
  }
41
41
 
42
+ /**
43
+ * Generated file names must be unique IGNORING case. On a case-insensitive disk
44
+ * (the macOS default) exports `brandColor` and `BrandColor` would be one file:
45
+ * the second write overwrote the first and Turbopack could not resolve one of
46
+ * the imports. Each component (`identity` = dir + name) owns one file name; a
47
+ * different component that wants a name already taken, in any case, gets a
48
+ * `.2`, `.3`, ... suffix. A dot cannot clash with the `__` directory separator
49
+ * or with a PascalCase base name. Claiming again with the same identity
50
+ * returns the same file name, so the same-name dedupe below still works.
51
+ */
52
+ function createFileNameClaimer() {
53
+ const owners = new Map(); // lowercased file name -> identity that owns it
54
+ const byIdentity = new Map(); // identity -> file name
55
+ return function claimFileName(desired, identity) {
56
+ if (byIdentity.has(identity)) return byIdentity.get(identity);
57
+ const stem = desired.replace(/\.tsx$/, '');
58
+ for (let n = 1; ; n++) {
59
+ const candidate = n === 1 ? desired : `${stem}.${n}.tsx`;
60
+ if (!owners.has(candidate.toLowerCase())) {
61
+ owners.set(candidate.toLowerCase(), identity);
62
+ byIdentity.set(identity, candidate);
63
+ return candidate;
64
+ }
65
+ }
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Delete files that differ from `fileName` only by case before writing it.
71
+ * Names are unique ignoring case within a run (createFileNameClaimer), so any
72
+ * such file is left over from an earlier run. Without this, a case-insensitive
73
+ * disk keeps the OLD casing (writeIfChanged skips identical content), and the
74
+ * case-sensitive stale cleanup then deletes what is really the live file.
75
+ */
76
+ async function removeCaseVariants(dir, fileName, existingNames) {
77
+ for (let i = existingNames.length - 1; i >= 0; i--) {
78
+ const name = existingNames[i];
79
+ if (name !== fileName && name.toLowerCase() === fileName.toLowerCase()) {
80
+ await fs.remove(path.join(dir, name));
81
+ existingNames.splice(i, 1);
82
+ }
83
+ }
84
+ }
85
+
42
86
  // Parse command line arguments
43
87
  const args = process.argv.slice(2);
44
88
  const projectArgIndex = args.indexOf('--project');
@@ -359,6 +403,8 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
359
403
 
360
404
  // Track all components and their files
361
405
  const componentInfo = []; // { name, fileName, needsClient }
406
+ const claimFileName = createFileNameClaimer();
407
+ const namesOnDisk = fs.existsSync(generatedDir) ? await fs.readdir(generatedDir) : [];
362
408
 
363
409
  for (const snippetFile of snippetFiles) {
364
410
  const { file, dir, fullPath: filePath } = snippetFile;
@@ -381,11 +427,12 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
381
427
  for (const exportName of exportNames) {
382
428
  // Use dir prefix in filename only (to avoid file collisions)
383
429
  const filePrefix = dir ? `${dir.replace(/\//g, '__')}__` : '';
384
- const fileName = `${filePrefix}${exportName}.tsx`;
430
+ const fileName = claimFileName(`${filePrefix}${exportName}.tsx`, `${dir}|${exportName}`);
385
431
  const componentFile = generateComponentFile(exportName, processedCode, needsClient, false);
386
432
 
387
433
  const filePath = path.join(generatedDir, fileName);
388
434
  generatedFiles.add(fileName);
435
+ await removeCaseVariants(generatedDir, fileName, namesOnDisk);
389
436
  const written = await writeIfChanged(filePath, componentFile);
390
437
  // Track with directory for alias mapping
391
438
  componentInfo.push({
@@ -406,11 +453,12 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
406
453
  const needsClient = needsClientDirective(wrappedCode);
407
454
  // Use dir prefix in filename only (double underscore to avoid collisions)
408
455
  const filePrefix = dir ? `${dir.replace(/\//g, '__')}__` : '';
409
- const fileName = `${filePrefix}${baseName}.tsx`;
456
+ const fileName = claimFileName(`${filePrefix}${baseName}.tsx`, `${dir}|${baseName}`);
410
457
 
411
458
  const componentFile = generateComponentFile(baseName, wrappedCode, needsClient, true);
412
459
  const filePath = path.join(generatedDir, fileName);
413
460
  generatedFiles.add(fileName);
461
+ await removeCaseVariants(generatedDir, fileName, namesOnDisk);
414
462
  const written = await writeIfChanged(filePath, componentFile);
415
463
  componentInfo.push({
416
464
  name: baseName,
@@ -458,14 +506,22 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
458
506
  const dedupedComponentInfo = [...seenComponentKeys.values()];
459
507
 
460
508
  // Build unique import names for each component (prefixed with dir if needed)
509
+ // Identifiers in the index must be unique too: in a subdirectory, `brandColor`
510
+ // and `BrandColor` both become `XBrandColor`, a duplicate import that breaks
511
+ // the whole index. Later duplicates get a `_2`, `_3`, ... suffix.
512
+ const usedImportNames = new Set();
461
513
  const importInfo = dedupedComponentInfo.map(c => {
462
514
  const prefix = c.dir ? toPascalCase(c.dir.replace(/\//g, '-')) : '';
463
- const uniqueName = prefix + c.name.charAt(0).toUpperCase() + c.name.slice(1);
515
+ const prefixed = prefix + c.name.charAt(0).toUpperCase() + c.name.slice(1);
516
+ const baseName = c.dir ? prefixed : c.name; // Only add prefix if in subdirectory
517
+ let uniqueName = baseName;
518
+ for (let n = 2; usedImportNames.has(uniqueName); n++) uniqueName = `${baseName}_${n}`;
519
+ usedImportNames.add(uniqueName);
464
520
  // Strip the .tsx extension from fileName to get the import path
465
521
  const importPath = c.fileName.replace(/\.tsx$/, '');
466
522
  return {
467
523
  ...c,
468
- uniqueName: c.dir ? uniqueName : c.name, // Only add prefix if in subdirectory
524
+ uniqueName,
469
525
  importPath
470
526
  };
471
527
  });
@@ -3264,9 +3264,9 @@
3264
3264
  }
3265
3265
  },
3266
3266
  "node_modules/dompurify": {
3267
- "version": "3.4.16",
3268
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
3269
- "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
3267
+ "version": "3.4.17",
3268
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
3269
+ "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
3270
3270
  "license": "(MPL-2.0 OR Apache-2.0)",
3271
3271
  "optionalDependencies": {
3272
3272
  "@types/trusted-types": "^2.0.7"