fontdue-js 3.2.0 → 3.2.2

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.
@@ -22,16 +22,44 @@
22
22
  // forged POST reveals nothing and buys the caller only their own dynamic
23
23
  // renders, exactly like a forged preview POST.
24
24
  //
25
+ // The bypass is scoped to the unlocked page's path, not the whole site.
26
+ // draftMode().enable() sets the cookie with Path=/, which would take EVERY
27
+ // page this visitor touches off the full-route cache — and because a render
28
+ // carrying a node-access token also opts all of its GraphQL fetches out of the
29
+ // shared data cache, one unlock made the visitor's whole browsing session
30
+ // dynamically rendered and measurably slow (seconds per page on heavy pages).
31
+ // Draft mode is decided per-request by whether the browser SENDS the bypass
32
+ // cookie, and the browser decides that by cookie Path — so the form posts the
33
+ // pathname it unlocked on and we narrow the cookie to it. Public pages stay on
34
+ // the static cache; only the unlocked collection's pages render dynamically
35
+ // for this visitor. Unlocking several collections yields several same-name
36
+ // cookies with different paths, which browsers store and send independently.
37
+ // (Admin preview keeps its site-wide Path=/ bypass — previewing the whole site
38
+ // dynamically is the point there.)
39
+ //
25
40
  // The bypass cookie is a session cookie, while the node-access cookie lasts ~30
26
41
  // days. A returning visitor whose bypass lapsed sees the static form again and
27
42
  // re-enters the password; re-unlocking re-enables the bypass. There is no
28
43
  // DELETE: draft mode is shared with admin preview, so an unlock exit must not
29
44
  // tear down an active preview — the bypass simply expires with the session.
30
45
 
31
- import { draftMode } from 'next/headers';
46
+ import { cookies, draftMode } from 'next/headers';
32
47
 
33
48
  /** Default path the templates mount the handler at. */
34
49
  export const UNLOCK_ENDPOINT = '/api/unlock';
50
+
51
+ // Next's draft-mode bypass cookie. The name is Next's stable, documented
52
+ // public contract (the same one draftMode().enable() sets), but it isn't
53
+ // exported from a public entry point, so it's spelled out here.
54
+ const PRERENDER_BYPASS_COOKIE = '__prerender_bypass';
55
+
56
+ // A client-sent pathname we are willing to put in a Set-Cookie Path attribute:
57
+ // absolute, bounded, visible ASCII with no ';' — i.e. what
58
+ // window.location.pathname (always percent-encoded) actually produces, and
59
+ // nothing that could terminate or escape the attribute (Next serializes the
60
+ // path verbatim). Anything else falls back to the site-wide default.
61
+ const SCOPE_PATH_MAX_LENGTH = 512;
62
+ const SCOPE_PATH_RE = /^\/[\x21-\x3a\x3c-\x7e]*$/;
35
63
  export async function POST(request) {
36
64
  const body = await request.json().catch(() => ({}));
37
65
  if (typeof body.token !== 'string' || body.token === '') {
@@ -43,7 +71,42 @@ export async function POST(request) {
43
71
  });
44
72
  }
45
73
  (await draftMode()).enable();
74
+ await scopeBypassCookie(scopePath(body.path));
46
75
  return Response.json({
47
76
  ok: true
48
77
  });
78
+ }
79
+
80
+ // The cookie Path to narrow the bypass to, or undefined to leave it site-wide
81
+ // (path missing — an older form — or unusable). Trailing slashes are stripped
82
+ // because cookie path-matching treats "/fonts/x" as covering both the page
83
+ // itself and everything under it, while "/fonts/x/" would miss the page.
84
+ function scopePath(path) {
85
+ if (typeof path !== 'string' || path.length > SCOPE_PATH_MAX_LENGTH || !SCOPE_PATH_RE.test(path)) {
86
+ return undefined;
87
+ }
88
+ const trimmed = path.replace(/\/+$/, '');
89
+ return trimmed === '' ? undefined : trimmed;
90
+ }
91
+
92
+ // Replace the pending site-wide bypass cookie enable() just queued with one
93
+ // scoped to `path`. cookies() in a route handler exposes the same mutable
94
+ // store enable() wrote to, and setting the same cookie name again replaces the
95
+ // queued Set-Cookie rather than adding a second one — so exactly one bypass
96
+ // cookie leaves this response, and no Path=/ variant that could clobber an
97
+ // admin's site-wide preview bypass. The explicit attributes mirror enable()'s
98
+ // as a fallback; the spread keeps whatever enable() actually set (bar the
99
+ // path) if Next ever changes them.
100
+ async function scopeBypassCookie(path) {
101
+ if (!path) return;
102
+ const jar = await cookies();
103
+ const bypass = jar.get(PRERENDER_BYPASS_COOKIE);
104
+ if (!(bypass !== null && bypass !== void 0 && bypass.value)) return;
105
+ jar.set({
106
+ httpOnly: true,
107
+ sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax',
108
+ secure: process.env.NODE_ENV !== 'development',
109
+ ...bypass,
110
+ path
111
+ });
49
112
  }
@@ -8,7 +8,7 @@ import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
8
8
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
9
9
  // Exported so UI (the admin toolbar) can surface it without re-reading the
10
10
  // build-time global in a 'use client' module.
11
- export const version = "3.2.0";
11
+ export const version = "3.2.2";
12
12
  const IS_SERVER = typeof window === typeof undefined;
13
13
 
14
14
  // Opt server fetches into Next's data cache only in production; dev stays
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.2.0",
3
+ "version": "3.2.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",
@@ -1,13 +1,33 @@
1
1
  // Minimal declaration so src/next/tenant.ts type-checks without `next`
2
2
  // installed (it's the host app's dependency; this package only ever runs the
3
3
  // import inside a Next.js server).
4
+ //
5
+ // The cookie shape mirrors Next's route-handler cookie store: get() also
6
+ // carries the queued cookie's attributes (the store is ResponseCookies-backed,
7
+ // so a cookie draftMode().enable() just set comes back with its path/flags),
8
+ // and set() with an existing name replaces the queued Set-Cookie. The unlock
9
+ // route (src/next/unlock.ts) relies on both to narrow the bypass cookie's
10
+ // path.
4
11
  declare module 'next/headers' {
12
+ interface CookieAttributes {
13
+ path?: string;
14
+ httpOnly?: boolean;
15
+ secure?: boolean;
16
+ sameSite?: 'strict' | 'lax' | 'none';
17
+ expires?: Date | number;
18
+ maxAge?: number;
19
+ domain?: string;
20
+ }
21
+
5
22
  export function draftMode(): Promise<{
6
23
  isEnabled: boolean;
7
24
  enable(): void;
8
25
  disable(): void;
9
26
  }>;
10
27
  export function cookies(): Promise<{
11
- get(name: string): { value: string } | undefined;
28
+ get(
29
+ name: string,
30
+ ): ({ name: string; value: string } & CookieAttributes) | undefined;
31
+ set(cookie: { name: string; value: string } & CookieAttributes): void;
12
32
  }>;
13
33
  }