@payload-onpage/next 0.3.2 → 0.4.1

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/README.md CHANGED
@@ -25,3 +25,7 @@ Part of [**payload-onpage**](https://github.com/jellologic/payload-onpage): on-p
25
25
  MIT licensed.
26
26
 
27
27
  See the **[tour](https://github.com/jellologic/payload-onpage/blob/main/docs/tour.md)** and **[getting started](https://github.com/jellologic/payload-onpage/blob/main/docs/getting-started.md)**.
28
+
29
+ ---
30
+
31
+ _Found a bug? [Report it](https://github.com/jellologic/payload-onpage/issues/new?template=bug_report.yml) or see [CONTRIBUTING.md](https://github.com/jellologic/payload-onpage/blob/main/CONTRIBUTING.md) to fix it yourself._
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The editor's `onpage` mode cookie: `<mode>`, or `<mode>:<authCookie>:<hash>`
3
+ * when bound to a session. Shared by the session helpers (server components)
4
+ * and the proxy, so both read a binding the same way.
5
+ */
6
+ import { type OnPageMode } from '@payload-onpage/core';
7
+ export declare const fingerprint: (token: string) => Promise<string>;
8
+ export interface ModeCookie {
9
+ mode: OnPageMode;
10
+ /** True when bound to an auth cookie that is present and matches. */
11
+ bound: boolean;
12
+ }
13
+ /** Parse the mode cookie; null when unknown or bound to a session that ended. */
14
+ export declare function readModeCookie(raw: string | undefined, cookie: (name: string) => string | undefined): Promise<ModeCookie | null>;
15
+ //# sourceMappingURL=binding.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"binding.d.ts","sourceRoot":"","sources":["../src/binding.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAG5E,eAAO,MAAM,WAAW,GAAU,OAAO,MAAM,oBAAgD,CAAA;AAE/F,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,UAAU,CAAA;IAChB,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAA;CACf;AAED,iFAAiF;AACjF,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAC3C,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAQ5B"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The editor's `onpage` mode cookie: `<mode>`, or `<mode>:<authCookie>:<hash>`
3
+ * when bound to a session. Shared by the session helpers (server components)
4
+ * and the proxy, so both read a binding the same way.
5
+ */
6
+ import { isOnPageMode, sha256 } from '@payload-onpage/core';
7
+ const HASH_LENGTH = 32;
8
+ export const fingerprint = async (token) => (await sha256(token)).slice(0, HASH_LENGTH);
9
+ /** Parse the mode cookie; null when unknown or bound to a session that ended. */
10
+ export async function readModeCookie(raw, cookie) {
11
+ if (!raw)
12
+ return null;
13
+ const [mode, bound, hash] = raw.split(':');
14
+ if (!isOnPageMode(mode))
15
+ return null;
16
+ if (!bound)
17
+ return { mode, bound: false };
18
+ const token = cookie(bound);
19
+ if (!token || (await fingerprint(token)) !== hash)
20
+ return null;
21
+ return { mode, bound: true };
22
+ }
23
+ //# sourceMappingURL=binding.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"binding.js","sourceRoot":"","sources":["../src/binding.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,EAAmB,MAAM,sBAAsB,CAAA;AAE5E,MAAM,WAAW,GAAG,EAAE,CAAA;AACtB,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,KAAa,EAAE,EAAE,CAAC,CAAC,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAA;AAQ/F,iFAAiF;AACjF,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAuB,EACvB,MAA4C;IAE5C,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAA;IACrB,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAC1C,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IACpC,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;IACzC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC3B,IAAI,CAAC,KAAK,IAAI,CAAC,MAAM,WAAW,CAAC,KAAK,CAAC,CAAC,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IAC9D,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;AAC9B,CAAC"}
package/dist/proxy.d.ts CHANGED
@@ -2,6 +2,17 @@ import { type NextMiddleware, type NextRequest } from 'next/server.js';
2
2
  export interface OnPageProxyOptions {
3
3
  /** Same secret as the plugin (default: Payload's `secret`, i.e. PAYLOAD_SECRET). */
4
4
  secret: string | undefined;
5
+ /**
6
+ * Strict draft guard. Next's draft cookie is one site-wide key, so once a
7
+ * preview-link viewer's browser holds it (`cdnBypass`), anyone who copies it
8
+ * could read every draft. With `allowDraft`, a request keeps the draft cookie
9
+ * only on the page its preview link covers, for an editor whose bound
10
+ * session is still signed in, or when `allowDraft` says so (e.g. your own
11
+ * preview route's users: check the session is an admin). Otherwise it's
12
+ * stripped before rendering. Without it, the cookie is stripped only when it
13
+ * arrives with a preview link for another page.
14
+ */
15
+ allowDraft?: (request: NextRequest) => boolean | Promise<boolean>;
5
16
  }
6
17
  /** Request headers for the rest of the pipeline, with draft mode injected for a valid grant. */
7
18
  export declare function withOnPage(request: NextRequest, opts: OnPageProxyOptions, headers?: Headers): Promise<Headers>;
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAgB,KAAK,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAEpF,MAAM,WAAW,kBAAkB;IACjC,oFAAoF;IACpF,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;CAC3B;AAQD,gGAAgG;AAChG,wBAAsB,UAAU,CAC9B,OAAO,EAAE,WAAW,EACpB,IAAI,EAAE,kBAAkB,EACxB,OAAO,GAAE,OAAsC,GAC9C,OAAO,CAAC,OAAO,CAAC,CAalB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,kBAAkB,GAAG,cAAc,CAQ1E"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAyBA,OAAO,EAAgB,KAAK,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAGpF,MAAM,WAAW,kBAAkB;IACjC,oFAAoF;IACpF,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1B;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CAClE;AAQD,gGAAgG;AAChG,wBAAsB,UAAU,CAC9B,OAAO,EAAE,WAAW,EACpB,IAAI,EAAE,kBAAkB,EACxB,OAAO,GAAE,OAAsC,GAC9C,OAAO,CAAC,OAAO,CAAC,CAqBlB;AAqBD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,kBAAkB,GAAG,cAAc,CAQ1E"}
package/dist/proxy.js CHANGED
@@ -2,9 +2,11 @@
2
2
  * The payload-onpage proxy (Next.js middleware). Required for preview links:
3
3
  * it unseals the `onpage_preview` grant and, only on the shared page and
4
4
  * before the grant expires, injects Next's draft-mode cookie into the request.
5
- * Static pages then render the draft for the viewer, while the browser never
6
- * holds a usable draft credential. It also passes the request path to server
7
- * components.
5
+ * Static pages then render the draft for the viewer. It also guards Next's
6
+ * draft cookie itself: on a CDN like Vercel's the viewer's browser holds it,
7
+ * scoped to the shared page (nextSession `cdnBypass`), and the proxy strips it
8
+ * from any request the grant doesn't cover, so a copied cookie unlocks nothing.
9
+ * It also passes the request path to server components.
8
10
  *
9
11
  * // proxy.ts (Next 16; middleware.ts on Next 15)
10
12
  * import { createOnPageProxy } from '@payload-onpage/next/proxy'
@@ -15,6 +17,7 @@
15
17
  */
16
18
  import { COOKIE, HEADER, SEAL_PURPOSE, isLivePreviewGrant, samePath, unseal, } from '@payload-onpage/core';
17
19
  import { NextResponse } from 'next/server.js';
20
+ import { readModeCookie } from "./binding.js";
18
21
  const withoutCookies = (header, names) => (header ?? '')
19
22
  .split(';')
20
23
  .map((c) => c.trim())
@@ -25,17 +28,33 @@ export async function withOnPage(request, opts, headers = new Headers(request.he
25
28
  headers.delete(HEADER.shared); // never trust it from the client
26
29
  headers.set(HEADER.path, path);
27
30
  const sealed = request.cookies.get(COOKIE.preview)?.value;
28
- if (!sealed || !opts.secret)
31
+ const grant = sealed && opts.secret ? await unseal(sealed, opts.secret, SEAL_PURPOSE.previewCookie) : null;
32
+ if (isLivePreviewGrant(grant) && samePath(path, grant.path)) {
33
+ const cookie = withoutCookies(headers.get('cookie'), [COOKIE.nextDraft, COOKIE.mode]);
34
+ cookie.push(`${COOKIE.nextDraft}=${grant.draft}`, `${COOKIE.mode}=view`);
35
+ headers.set('cookie', cookie.join('; '));
36
+ headers.set(HEADER.shared, '1');
29
37
  return headers;
30
- const grant = await unseal(sealed, opts.secret, SEAL_PURPOSE.previewCookie);
31
- if (!isLivePreviewGrant(grant) || !samePath(path, grant.path))
32
- return headers;
33
- const cookie = withoutCookies(headers.get('cookie'), [COOKIE.nextDraft, COOKIE.mode]);
34
- cookie.push(`${COOKIE.nextDraft}=${grant.draft}`, `${COOKIE.mode}=view`);
35
- headers.set('cookie', cookie.join('; '));
36
- headers.set(HEADER.shared, '1');
38
+ }
39
+ if (request.cookies.has(COOKIE.nextDraft) && !(await mayKeepDraft(request, opts, !!sealed))) {
40
+ headers.set('cookie', withoutCookies(headers.get('cookie'), [COOKIE.nextDraft, COOKIE.mode]).join('; '));
41
+ }
37
42
  return headers;
38
43
  }
44
+ /** No preview grant covers this page: may the request keep its own draft cookie? */
45
+ async function mayKeepDraft(request, opts, hasPreviewCookie) {
46
+ if (!opts.allowDraft)
47
+ return !hasPreviewCookie;
48
+ const mode = await readModeCookie(request.cookies.get(COOKIE.mode)?.value, (name) => request.cookies.get(name)?.value);
49
+ if (mode?.bound)
50
+ return true; // an editor, still signed in as the session that entered
51
+ try {
52
+ return await opts.allowDraft(request);
53
+ }
54
+ catch {
55
+ return false; // fail closed: published content, never someone else's drafts
56
+ }
57
+ }
39
58
  export function createOnPageProxy(opts) {
40
59
  if (!opts.secret)
41
60
  console.warn('payload-onpage: createOnPageProxy() has no secret; preview links will show published content.');
package/dist/proxy.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EACL,MAAM,EACN,MAAM,EACN,YAAY,EACZ,kBAAkB,EAClB,QAAQ,EACR,MAAM,GACP,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAyC,MAAM,gBAAgB,CAAA;AAOpF,MAAM,cAAc,GAAG,CAAC,MAAqB,EAAE,KAAe,EAAE,EAAE,CAChE,CAAC,MAAM,IAAI,EAAE,CAAC;KACX,KAAK,CAAC,GAAG,CAAC;KACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;KACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;AAElE,gGAAgG;AAChG,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,OAAoB,EACpB,IAAwB,EACxB,UAAmB,IAAI,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC;IAE/C,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAA;IACrC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA,CAAC,iCAAiC;IAC/D,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;IAC9B,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,KAAK,CAAA;IACzD,IAAI,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,MAAM;QAAE,OAAO,OAAO,CAAA;IAC3C,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC,aAAa,CAAC,CAAA;IAC3E,IAAI,CAAC,kBAAkB,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC;QAAE,OAAO,OAAO,CAAA;IAC7E,MAAM,MAAM,GAAG,cAAc,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;IACrF,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,SAAS,IAAI,KAAK,CAAC,KAAK,EAAE,EAAE,GAAG,MAAM,CAAC,IAAI,OAAO,CAAC,CAAA;IACxE,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;IACxC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC/B,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAwB;IACxD,IAAI,CAAC,IAAI,CAAC,MAAM;QACd,OAAO,CAAC,IAAI,CACV,+FAA+F,CAChG,CAAA;IACH,OAAO,KAAK,UAAU,KAAK,CAAC,OAAoB;QAC9C,OAAO,YAAY,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAA;IACrF,CAAC,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"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"}
package/dist/session.d.ts CHANGED
@@ -4,9 +4,12 @@
4
4
  * Editors: Next draft mode plus an `onpage` cookie saying which mode, bound to
5
5
  * a hash of their auth cookie, so signing out (or in as someone else) ends it.
6
6
  *
7
- * Preview links: the browser gets only a sealed grant (`onpage_preview`). The
8
- * proxy (./proxy) unseals it and injects draft mode into the request for the
9
- * shared page only. The raw Next.js draft secret never reaches the browser.
7
+ * Preview links: the browser gets a sealed grant (`onpage_preview`). The proxy
8
+ * (./proxy) unseals it and injects draft mode into the request for the shared
9
+ * page only. With `cdnBypass` (default on Vercel), the browser also gets Next's
10
+ * draft cookie scoped to that page's path and the link's expiry: Vercel's cache
11
+ * decides bypass from the browser's own cookies, before any proxy runs. The
12
+ * proxy refuses that cookie everywhere the grant doesn't cover.
10
13
  *
11
14
  * All helpers read `draftMode().isEnabled` first and touch cookies only when
12
15
  * draft mode is on. Next exempts that read from prerender bailout, so pages
@@ -16,6 +19,14 @@ import { CONTEXT_KEY, type OnPageMode, type SessionAdapter } from '@payload-onpa
16
19
  export interface NextSessionOptions {
17
20
  /** How long an editor's edit/preview mode lasts, in seconds. Default 12 hours. */
18
21
  maxAge?: number;
22
+ /**
23
+ * Give preview-link viewers Next's draft cookie, scoped to the shared page's
24
+ * path and expiring with the link, so a CDN that serves cached pages unless
25
+ * the browser itself sends the draft cookie (Vercel) shows them the draft.
26
+ * Requires the proxy (`withOnPage`), which strips it on every other page.
27
+ * Default: on when deployed on Vercel (`process.env.VERCEL`).
28
+ */
29
+ cdnBypass?: boolean;
19
30
  }
20
31
  export declare function nextSession(opts?: NextSessionOptions): SessionAdapter;
21
32
  /** "edit", "view" or null for this request. Safe to call anywhere (returns null outside a request). */
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EACL,WAAW,EAKX,KAAK,UAAU,EACf,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAA;AAG7B,MAAM,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AA0CD,wBAAgB,WAAW,CAAC,IAAI,GAAE,kBAAuB,GAAG,cAAc,CA2BzE;AAED,uGAAuG;AACvG,eAAO,MAAM,aAAa,QAAO,OAAO,CAAC,UAAU,GAAG,IAAI,CAIhD,CAAA;AAEV;;;;GAIG;AACH,eAAO,MAAM,cAAc,QAAO,OAAO,CAAC,OAAO,CAKtC,CAAA;AAEX,8DAA8D;AAC9D,eAAO,MAAM,eAAe,QAAO,OAAO,CAAC,OAAO,CACwB,CAAA;AAE1E,kEAAkE;AAClE,MAAM,MAAM,iBAAiB,GAAG;IAAE,OAAO,CAAC,EAAE;QAAE,CAAC,WAAW,CAAC,EAAE,IAAI,CAAA;KAAE,CAAA;CAAE,CAAA;AAErE;;;;;GAKG;AACH,wBAAsB,aAAa,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEhE"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EACL,WAAW,EAGX,KAAK,UAAU,EACf,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAA;AAI7B,MAAM,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AA+BD,wBAAgB,WAAW,CAAC,IAAI,GAAE,kBAAuB,GAAG,cAAc,CAsCzE;AAED,uGAAuG;AACvG,eAAO,MAAM,aAAa,QAAO,OAAO,CAAC,UAAU,GAAG,IAAI,CAIhD,CAAA;AAEV;;;;GAIG;AACH,eAAO,MAAM,cAAc,QAAO,OAAO,CAAC,OAAO,CAKtC,CAAA;AAEX,8DAA8D;AAC9D,eAAO,MAAM,eAAe,QAAO,OAAO,CAAC,OAAO,CACwB,CAAA;AAE1E,kEAAkE;AAClE,MAAM,MAAM,iBAAiB,GAAG;IAAE,OAAO,CAAC,EAAE;QAAE,CAAC,WAAW,CAAC,EAAE,IAAI,CAAA;KAAE,CAAA;CAAE,CAAA;AAErE;;;;;GAKG;AACH,wBAAsB,aAAa,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEhE"}
package/dist/session.js CHANGED
@@ -4,16 +4,20 @@
4
4
  * Editors: Next draft mode plus an `onpage` cookie saying which mode, bound to
5
5
  * a hash of their auth cookie, so signing out (or in as someone else) ends it.
6
6
  *
7
- * Preview links: the browser gets only a sealed grant (`onpage_preview`). The
8
- * proxy (./proxy) unseals it and injects draft mode into the request for the
9
- * shared page only. The raw Next.js draft secret never reaches the browser.
7
+ * Preview links: the browser gets a sealed grant (`onpage_preview`). The proxy
8
+ * (./proxy) unseals it and injects draft mode into the request for the shared
9
+ * page only. With `cdnBypass` (default on Vercel), the browser also gets Next's
10
+ * draft cookie scoped to that page's path and the link's expiry: Vercel's cache
11
+ * decides bypass from the browser's own cookies, before any proxy runs. The
12
+ * proxy refuses that cookie everywhere the grant doesn't cover.
10
13
  *
11
14
  * All helpers read `draftMode().isEnabled` first and touch cookies only when
12
15
  * draft mode is on. Next exempts that read from prerender bailout, so pages
13
16
  * stay static for visitors.
14
17
  */
15
- import { CONTEXT_KEY, COOKIE, HEADER, isOnPageMode, sha256, } from '@payload-onpage/core';
18
+ import { CONTEXT_KEY, COOKIE, HEADER, } from '@payload-onpage/core';
16
19
  import { cookies, draftMode, headers } from 'next/headers.js';
20
+ import { fingerprint, readModeCookie } from "./binding.js";
17
21
  /** Our cookies: server-only, same-site, whole site. */
18
22
  const cookieBase = (secure) => ({ httpOnly: true, sameSite: 'lax', secure, path: '/' });
19
23
  /**
@@ -28,29 +32,17 @@ async function inDraft(read, fallback) {
28
32
  return fallback;
29
33
  }
30
34
  }
31
- const HASH_LENGTH = 32;
32
- const fingerprint = async (token) => (await sha256(token)).slice(0, HASH_LENGTH);
33
35
  /** `edit` | `view`, or `<mode>:<authCookie>:<hash>` when bound to a session. */
34
36
  async function modeValue(mode, jar, bindTo) {
35
37
  const token = bindTo ? jar.get(bindTo)?.value : undefined;
36
38
  return token ? `${mode}:${bindTo}:${await fingerprint(token)}` : mode;
37
39
  }
38
- /** The mode a cookie value grants in this request, or null (unknown, or the bound session is gone). */
39
40
  async function readMode(raw, jar) {
40
- if (!raw)
41
- return null;
42
- const [mode, bound, hash] = raw.split(':');
43
- if (!isOnPageMode(mode))
44
- return null;
45
- if (bound) {
46
- const token = jar.get(bound)?.value;
47
- if (!token || (await fingerprint(token)) !== hash)
48
- return null;
49
- }
50
- return mode;
41
+ return (await readModeCookie(raw, (name) => jar.get(name)?.value))?.mode ?? null;
51
42
  }
52
43
  export function nextSession(opts = {}) {
53
44
  const maxAge = opts.maxAge ?? 12 * 3600;
45
+ const cdnBypass = opts.cdnBypass ?? (typeof process !== 'undefined' && !!process.env.VERCEL);
54
46
  return {
55
47
  async enable(mode, { secure, bindTo }) {
56
48
  ;
@@ -59,22 +51,34 @@ export function nextSession(opts = {}) {
59
51
  jar.set(COOKIE.mode, await modeValue(mode, jar, bindTo), { ...cookieBase(secure), maxAge });
60
52
  jar.delete(COOKIE.preview);
61
53
  },
62
- async enableLink({ secure, path: _path, expiresAt, seal }) {
54
+ async enableLink({ secure, path, expiresAt, seal }) {
63
55
  ;
64
56
  (await draftMode()).enable();
65
57
  const jar = await cookies();
66
58
  const draft = jar.get(COOKIE.nextDraft)?.value;
67
59
  if (!draft)
68
60
  throw new Error('payload-onpage: Next.js did not set its draft-mode cookie');
69
- // Keep the draft secret server-side: the browser only gets the sealed grant.
70
- jar.delete(COOKIE.nextDraft);
61
+ if (cdnBypass) {
62
+ // Replaces the site-wide cookie draftMode() just set: this page, this link's lifetime.
63
+ jar.set(COOKIE.nextDraft, draft, { ...cookieBase(secure), path, expires: expiresAt });
64
+ }
65
+ else {
66
+ // Keep the draft secret server-side: the browser only gets the sealed grant.
67
+ jar.delete(COOKIE.nextDraft);
68
+ }
71
69
  jar.delete(COOKIE.mode);
72
70
  jar.set(COOKIE.preview, await seal(draft), { ...cookieBase(secure), expires: expiresAt });
73
71
  },
74
- async disable() {
75
- ;
76
- (await draftMode()).disable();
72
+ async disable({ path } = {}) {
77
73
  const jar = await cookies();
74
+ if (path && jar.get(COOKIE.preview)) {
75
+ // A preview-link viewer: their draft cookie, if any, is scoped to the shared page.
76
+ jar.set(COOKIE.nextDraft, '', { ...cookieBase(false), path, maxAge: 0 });
77
+ }
78
+ else {
79
+ ;
80
+ (await draftMode()).disable();
81
+ }
78
82
  jar.delete(COOKIE.mode);
79
83
  jar.delete(COOKIE.preview);
80
84
  },
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payload-onpage/next",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "payload-onpage for Next.js App Router: draft-mode session adapter, <OnPage />, onpageContext() and refresh rendering.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "nextjs",
21
21
  "next.js",
22
22
  "app-router",
23
+ "typescript",
23
24
  "visual-editing",
24
25
  "visual-editor",
25
26
  "inline-editing",
@@ -72,9 +73,9 @@
72
73
  "provenance": true
73
74
  },
74
75
  "dependencies": {
75
- "@payload-onpage/core": "0.3.2",
76
- "@payload-onpage/react": "0.3.2",
77
- "@payload-onpage/lexical": "0.3.2"
76
+ "@payload-onpage/core": "0.4.1",
77
+ "@payload-onpage/react": "0.4.1",
78
+ "@payload-onpage/lexical": "0.4.1"
78
79
  },
79
80
  "peerDependencies": {
80
81
  "next": "^15.4.11 || ^16.3.8",
package/src/binding.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The editor's `onpage` mode cookie: `<mode>`, or `<mode>:<authCookie>:<hash>`
3
+ * when bound to a session. Shared by the session helpers (server components)
4
+ * and the proxy, so both read a binding the same way.
5
+ */
6
+ import { isOnPageMode, sha256, type OnPageMode } from '@payload-onpage/core'
7
+
8
+ const HASH_LENGTH = 32
9
+ export const fingerprint = async (token: string) => (await sha256(token)).slice(0, HASH_LENGTH)
10
+
11
+ export interface ModeCookie {
12
+ mode: OnPageMode
13
+ /** True when bound to an auth cookie that is present and matches. */
14
+ bound: boolean
15
+ }
16
+
17
+ /** Parse the mode cookie; null when unknown or bound to a session that ended. */
18
+ export async function readModeCookie(
19
+ raw: string | undefined,
20
+ cookie: (name: string) => string | undefined,
21
+ ): Promise<ModeCookie | null> {
22
+ if (!raw) return null
23
+ const [mode, bound, hash] = raw.split(':')
24
+ if (!isOnPageMode(mode)) return null
25
+ if (!bound) return { mode, bound: false }
26
+ const token = cookie(bound)
27
+ if (!token || (await fingerprint(token)) !== hash) return null
28
+ return { mode, bound: true }
29
+ }
package/src/proxy.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * The payload-onpage proxy (Next.js middleware). Required for preview links:
3
3
  * it unseals the `onpage_preview` grant and, only on the shared page and
4
4
  * before the grant expires, injects Next's draft-mode cookie into the request.
5
- * Static pages then render the draft for the viewer, while the browser never
6
- * holds a usable draft credential. It also passes the request path to server
7
- * components.
5
+ * Static pages then render the draft for the viewer. It also guards Next's
6
+ * draft cookie itself: on a CDN like Vercel's the viewer's browser holds it,
7
+ * scoped to the shared page (nextSession `cdnBypass`), and the proxy strips it
8
+ * from any request the grant doesn't cover, so a copied cookie unlocks nothing.
9
+ * It also passes the request path to server components.
8
10
  *
9
11
  * // proxy.ts (Next 16; middleware.ts on Next 15)
10
12
  * import { createOnPageProxy } from '@payload-onpage/next/proxy'
@@ -22,10 +24,22 @@ import {
22
24
  unseal,
23
25
  } from '@payload-onpage/core'
24
26
  import { NextResponse, type NextMiddleware, type NextRequest } from 'next/server.js'
27
+ import { readModeCookie } from './binding.ts'
25
28
 
26
29
  export interface OnPageProxyOptions {
27
30
  /** Same secret as the plugin (default: Payload's `secret`, i.e. PAYLOAD_SECRET). */
28
31
  secret: string | undefined
32
+ /**
33
+ * Strict draft guard. Next's draft cookie is one site-wide key, so once a
34
+ * preview-link viewer's browser holds it (`cdnBypass`), anyone who copies it
35
+ * could read every draft. With `allowDraft`, a request keeps the draft cookie
36
+ * only on the page its preview link covers, for an editor whose bound
37
+ * session is still signed in, or when `allowDraft` says so (e.g. your own
38
+ * preview route's users: check the session is an admin). Otherwise it's
39
+ * stripped before rendering. Without it, the cookie is stripped only when it
40
+ * arrives with a preview link for another page.
41
+ */
42
+ allowDraft?: (request: NextRequest) => boolean | Promise<boolean>
29
43
  }
30
44
 
31
45
  const withoutCookies = (header: string | null, names: string[]) =>
@@ -44,16 +58,43 @@ export async function withOnPage(
44
58
  headers.delete(HEADER.shared) // never trust it from the client
45
59
  headers.set(HEADER.path, path)
46
60
  const sealed = request.cookies.get(COOKIE.preview)?.value
47
- if (!sealed || !opts.secret) return headers
48
- const grant = await unseal(sealed, opts.secret, SEAL_PURPOSE.previewCookie)
49
- if (!isLivePreviewGrant(grant) || !samePath(path, grant.path)) return headers
50
- const cookie = withoutCookies(headers.get('cookie'), [COOKIE.nextDraft, COOKIE.mode])
51
- cookie.push(`${COOKIE.nextDraft}=${grant.draft}`, `${COOKIE.mode}=view`)
52
- headers.set('cookie', cookie.join('; '))
53
- headers.set(HEADER.shared, '1')
61
+ const grant =
62
+ sealed && opts.secret ? await unseal(sealed, opts.secret, SEAL_PURPOSE.previewCookie) : null
63
+ if (isLivePreviewGrant(grant) && samePath(path, grant.path)) {
64
+ const cookie = withoutCookies(headers.get('cookie'), [COOKIE.nextDraft, COOKIE.mode])
65
+ cookie.push(`${COOKIE.nextDraft}=${grant.draft}`, `${COOKIE.mode}=view`)
66
+ headers.set('cookie', cookie.join('; '))
67
+ headers.set(HEADER.shared, '1')
68
+ return headers
69
+ }
70
+ if (request.cookies.has(COOKIE.nextDraft) && !(await mayKeepDraft(request, opts, !!sealed))) {
71
+ headers.set(
72
+ 'cookie',
73
+ withoutCookies(headers.get('cookie'), [COOKIE.nextDraft, COOKIE.mode]).join('; '),
74
+ )
75
+ }
54
76
  return headers
55
77
  }
56
78
 
79
+ /** No preview grant covers this page: may the request keep its own draft cookie? */
80
+ async function mayKeepDraft(
81
+ request: NextRequest,
82
+ opts: OnPageProxyOptions,
83
+ hasPreviewCookie: boolean,
84
+ ): Promise<boolean> {
85
+ if (!opts.allowDraft) return !hasPreviewCookie
86
+ const mode = await readModeCookie(
87
+ request.cookies.get(COOKIE.mode)?.value,
88
+ (name) => request.cookies.get(name)?.value,
89
+ )
90
+ if (mode?.bound) return true // an editor, still signed in as the session that entered
91
+ try {
92
+ return await opts.allowDraft(request)
93
+ } catch {
94
+ return false // fail closed: published content, never someone else's drafts
95
+ }
96
+ }
97
+
57
98
  export function createOnPageProxy(opts: OnPageProxyOptions): NextMiddleware {
58
99
  if (!opts.secret)
59
100
  console.warn(
package/src/session.ts CHANGED
@@ -4,9 +4,12 @@
4
4
  * Editors: Next draft mode plus an `onpage` cookie saying which mode, bound to
5
5
  * a hash of their auth cookie, so signing out (or in as someone else) ends it.
6
6
  *
7
- * Preview links: the browser gets only a sealed grant (`onpage_preview`). The
8
- * proxy (./proxy) unseals it and injects draft mode into the request for the
9
- * shared page only. The raw Next.js draft secret never reaches the browser.
7
+ * Preview links: the browser gets a sealed grant (`onpage_preview`). The proxy
8
+ * (./proxy) unseals it and injects draft mode into the request for the shared
9
+ * page only. With `cdnBypass` (default on Vercel), the browser also gets Next's
10
+ * draft cookie scoped to that page's path and the link's expiry: Vercel's cache
11
+ * decides bypass from the browser's own cookies, before any proxy runs. The
12
+ * proxy refuses that cookie everywhere the grant doesn't cover.
10
13
  *
11
14
  * All helpers read `draftMode().isEnabled` first and touch cookies only when
12
15
  * draft mode is on. Next exempts that read from prerender bailout, so pages
@@ -16,16 +19,23 @@ import {
16
19
  CONTEXT_KEY,
17
20
  COOKIE,
18
21
  HEADER,
19
- isOnPageMode,
20
- sha256,
21
22
  type OnPageMode,
22
23
  type SessionAdapter,
23
24
  } from '@payload-onpage/core'
24
25
  import { cookies, draftMode, headers } from 'next/headers.js'
26
+ import { fingerprint, readModeCookie } from './binding.ts'
25
27
 
26
28
  export interface NextSessionOptions {
27
29
  /** How long an editor's edit/preview mode lasts, in seconds. Default 12 hours. */
28
30
  maxAge?: number
31
+ /**
32
+ * Give preview-link viewers Next's draft cookie, scoped to the shared page's
33
+ * path and expiring with the link, so a CDN that serves cached pages unless
34
+ * the browser itself sends the draft cookie (Vercel) shows them the draft.
35
+ * Requires the proxy (`withOnPage`), which strips it on every other page.
36
+ * Default: on when deployed on Vercel (`process.env.VERCEL`).
37
+ */
38
+ cdnBypass?: boolean
29
39
  }
30
40
 
31
41
  type Jar = Awaited<ReturnType<typeof cookies>>
@@ -47,29 +57,19 @@ async function inDraft<T>(read: () => Promise<T>, fallback: T): Promise<T> {
47
57
  }
48
58
  }
49
59
 
50
- const HASH_LENGTH = 32
51
- const fingerprint = async (token: string) => (await sha256(token)).slice(0, HASH_LENGTH)
52
-
53
60
  /** `edit` | `view`, or `<mode>:<authCookie>:<hash>` when bound to a session. */
54
61
  async function modeValue(mode: OnPageMode, jar: Jar, bindTo?: string): Promise<string> {
55
62
  const token = bindTo ? jar.get(bindTo)?.value : undefined
56
63
  return token ? `${mode}:${bindTo}:${await fingerprint(token)}` : mode
57
64
  }
58
65
 
59
- /** The mode a cookie value grants in this request, or null (unknown, or the bound session is gone). */
60
66
  async function readMode(raw: string | undefined, jar: Jar): Promise<OnPageMode | null> {
61
- if (!raw) return null
62
- const [mode, bound, hash] = raw.split(':')
63
- if (!isOnPageMode(mode)) return null
64
- if (bound) {
65
- const token = jar.get(bound)?.value
66
- if (!token || (await fingerprint(token)) !== hash) return null
67
- }
68
- return mode
67
+ return (await readModeCookie(raw, (name) => jar.get(name)?.value))?.mode ?? null
69
68
  }
70
69
 
71
70
  export function nextSession(opts: NextSessionOptions = {}): SessionAdapter {
72
71
  const maxAge = opts.maxAge ?? 12 * 3600
72
+ const cdnBypass = opts.cdnBypass ?? (typeof process !== 'undefined' && !!process.env.VERCEL)
73
73
  return {
74
74
  async enable(mode, { secure, bindTo }) {
75
75
  ;(await draftMode()).enable()
@@ -77,19 +77,29 @@ export function nextSession(opts: NextSessionOptions = {}): SessionAdapter {
77
77
  jar.set(COOKIE.mode, await modeValue(mode, jar, bindTo), { ...cookieBase(secure), maxAge })
78
78
  jar.delete(COOKIE.preview)
79
79
  },
80
- async enableLink({ secure, path: _path, expiresAt, seal }) {
80
+ async enableLink({ secure, path, expiresAt, seal }) {
81
81
  ;(await draftMode()).enable()
82
82
  const jar = await cookies()
83
83
  const draft = jar.get(COOKIE.nextDraft)?.value
84
84
  if (!draft) throw new Error('payload-onpage: Next.js did not set its draft-mode cookie')
85
- // Keep the draft secret server-side: the browser only gets the sealed grant.
86
- jar.delete(COOKIE.nextDraft)
85
+ if (cdnBypass) {
86
+ // Replaces the site-wide cookie draftMode() just set: this page, this link's lifetime.
87
+ jar.set(COOKIE.nextDraft, draft, { ...cookieBase(secure), path, expires: expiresAt })
88
+ } else {
89
+ // Keep the draft secret server-side: the browser only gets the sealed grant.
90
+ jar.delete(COOKIE.nextDraft)
91
+ }
87
92
  jar.delete(COOKIE.mode)
88
93
  jar.set(COOKIE.preview, await seal(draft), { ...cookieBase(secure), expires: expiresAt })
89
94
  },
90
- async disable() {
91
- ;(await draftMode()).disable()
95
+ async disable({ path } = {}) {
92
96
  const jar = await cookies()
97
+ if (path && jar.get(COOKIE.preview)) {
98
+ // A preview-link viewer: their draft cookie, if any, is scoped to the shared page.
99
+ jar.set(COOKIE.nextDraft, '', { ...cookieBase(false), path, maxAge: 0 })
100
+ } else {
101
+ ;(await draftMode()).disable()
102
+ }
93
103
  jar.delete(COOKIE.mode)
94
104
  jar.delete(COOKIE.preview)
95
105
  },