@capacms/sdk 1.0.0-next.7 → 1.0.0-next.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - 1.0.0-next.8. Preview on a live site, in draft mode on its production
6
+ domain. `preview()` takes the legacy key a site already holds (`pk_`, `sk_`
7
+ or unprefixed), as the API does: Capa checks the token with any key that
8
+ reads the project, and refuses a token made for another project. It used to
9
+ throw a `TypeError` for a legacy key before any request. Draft reads through
10
+ the SDK still take a `cap_` key only, and the legacy-key warning says so.
11
+ - Next.js: `createPreviewRoute` and `exitPreviewRoute` take Next's `cookies`.
12
+ Given it, the draft cookie is re-set
13
+ `HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`
14
+ (`maxAge` changes the hour), so Safari 26.2 and later send it inside the
15
+ editor's frame and it no longer lasts until the browser closes. Exit and a
16
+ bad link delete it partitioned, which is the only deletion that reaches
17
+ it. `redirect` is optional now: left out, each route answers with its own
18
+ 307, marked `X-Robots-Tag: noindex, nofollow`,
19
+ `Referrer-Policy: no-referrer` and `Cache-Control: private, no-store`, and
20
+ the preview route's carries the editor's `frame-ancestors`. Given
21
+ `redirect`, a route calls it as before.
22
+ - Next.js: `capaHeaders({ adminOrigins })` returns rules for `next.config`'s
23
+ `headers()` that send `X-Robots-Tag: noindex, nofollow` and
24
+ `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
25
+ on a request carrying the draft cookie or a `capa-preview`, `capa-edit` or
26
+ `capa-view` query, and on nothing else, so a visitor's response is
27
+ unchanged. Also exported: `draftHeaders`, `frameAncestors` (it throws for a
28
+ value that is not a bare origin), `frameDraftCookie`, `clearDraftCookie`,
29
+ `CAPA_ADMIN_ORIGIN`, `DRAFT_ROBOTS_TAG`, `DRAFT_COOKIE_MAX_AGE`,
30
+ `PREVIEW_PARAM` and `VIEW_PARAM`.
31
+ - Next.js: `capaMiddleware` marks every edit-mode response, and every request
32
+ carrying `capa-edit` or `capa-view`, `X-Robots-Tag: noindex, nofollow`, and
33
+ `resolveEditRequest` returns that value as `robotsTag`.
34
+ - Docs: "Add preview to a live site without changing it", the steps for a
35
+ site that keeps its own Capa reads, and why `editMode()` and
36
+ `getCapaClient()` make a static page dynamic.
5
37
  - 1.0.0-next.7. Browsers: the `/api/` and legacy clients called the platform `fetch` as a
6
38
  method of their config, which a browser refuses ("Failed to execute 'fetch'
7
39
  on 'Window': Illegal invocation"), so every read from a browser failed unless
@@ -165,7 +197,7 @@
165
197
  as it takes a tool spec.
166
198
 
167
199
  The tool spec. `buildGraphQLQuery`, `graphqlToSelect` and `selectToGraphQL`
168
- move between the spec the Explorer and `@capa/mcp` share, GraphQL text and
200
+ move between the spec the Explorer and `@capacms/mcp` share, GraphQL text and
169
201
  the equal REST request, written exactly as the API writes it in
170
202
  `extensions.capa.rest`: the filter as `where` JSON, and a system key a
171
203
  field shadows as `$tags`, `$createdAt` or `author.$id`. They nest at most 4
package/README.md CHANGED
@@ -47,12 +47,14 @@ under Developers > Keys.
47
47
  The legacy key your site already holds works too, for every read: a `pk_`
48
48
  or `sk_` key, or an older key with no prefix, since every key but `cap_` is
49
49
  a legacy key to the API. That covers `entries`, `graphql`,
50
- `graphqlSchema`, `pages`, `me` and `versions`. The first client built with
51
- one prints a warning once per process, naming the `cap_` key to mint. Draft
52
- previews keep their rule and take a `cap_` key only: `preview()`,
53
- `draftClient`'s `draft` config, and `CAPA_DRAFT_KEY` for `getCapaClient` and
54
- `graphql()` throw a `TypeError` for a legacy key before any request. An
55
- `apiKey` that is not a string is refused where the client is built.
50
+ `graphqlSchema`, `pages`, `me`, `versions` and `preview`: a site checks an
51
+ editor's preview link with the key it already holds, and Capa refuses a link
52
+ made for another project. The first client built with one prints a warning
53
+ once per process, naming the `cap_` key to mint. Draft reads through the SDK
54
+ keep their rule and take a `cap_` key only: `draftClient`'s `draft` config,
55
+ and `CAPA_DRAFT_KEY` for `getCapaClient` and `graphql()`, throw a `TypeError`
56
+ for a legacy key before any request. An `apiKey` that is not a string is
57
+ refused where the client is built.
56
58
 
57
59
  `CAPA_API_URL` and `CAPA_KEY` are the names every Capa tool reads: the
58
60
  `/nextjs` helpers, `capa-codegen` and Capa's MCP server, so one `.env` serves
@@ -1273,8 +1275,9 @@ editor sees the path without a link to open it.
1273
1275
 
1274
1276
  ### Quick start (Next.js)
1275
1277
 
1276
- Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`) and `CAPA_DRAFT_KEY` (`cap_test_`).
1277
- Draft previews take `cap_` keys only (see Keys). Then:
1278
+ Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`, or the legacy key your site
1279
+ holds) and `CAPA_DRAFT_KEY` (`cap_test_`). Draft reads through the SDK take a
1280
+ `cap_` key only (see Keys). Then:
1278
1281
 
1279
1282
  ```ts
1280
1283
  // middleware.ts
@@ -1283,10 +1286,13 @@ import { capaMiddleware } from "@capacms/sdk/nextjs";
1283
1286
  export const middleware = capaMiddleware({ NextResponse });
1284
1287
 
1285
1288
  // app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute)
1286
- import { draftMode } from "next/headers";
1287
- import { redirect } from "next/navigation";
1289
+ import { cookies, draftMode } from "next/headers";
1288
1290
  import { createPreviewRoute } from "@capacms/sdk/nextjs";
1289
- export const GET = createPreviewRoute({ draftMode, redirect });
1291
+ export const GET = createPreviewRoute({ draftMode, cookies });
1292
+
1293
+ // next.config.mjs
1294
+ import { capaHeaders } from "@capacms/sdk/nextjs";
1295
+ export default { async headers() { return capaHeaders(); } };
1290
1296
 
1291
1297
  // in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
1292
1298
  // in the root layout: const edit = await editMode({ draftMode, headers }) from @capacms/sdk/nextjs,
@@ -1346,11 +1352,20 @@ payload, or your own JSON endpoint. The page then shows the draft with no
1346
1352
  the draft or edit state alongside the entry and hand it to `capaAttrs`:
1347
1353
 
1348
1354
  ```tsx
1349
- // getServerSideProps, a +page.server.ts load, useAsyncData on the server...
1350
- return { props: { entry, edit: draft } };
1355
+ // pages/articles/[id].tsx on the Pages Router. A +page.server.ts load or
1356
+ // useAsyncData on the server hands over the flag the same way.
1357
+ import type { Entry } from "@capacms/sdk/next";
1358
+ import type { Articles } from "./capa-types";
1359
+
1360
+ export async function getServerSideProps({ draftMode = false }) {
1361
+ const entry = (await capa.entries.get<Articles>("articles", "entry-id"))!.data;
1362
+ return { props: { entry, edit: draftMode } };
1363
+ }
1351
1364
 
1352
1365
  // in the component
1353
- <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>
1366
+ export default function Page({ entry, edit }: { entry: Entry<Articles>; edit: boolean }) {
1367
+ return <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>;
1368
+ }
1354
1369
  ```
1355
1370
 
1356
1371
  Or re-mark the entries where they arrive with `markEditEntries(data)` when the
@@ -1388,6 +1403,7 @@ export async function middleware(request: NextRequest) {
1388
1403
  const edit = await resolveEditRequest(request, publishedClient());
1389
1404
  const response = NextResponse.next({ request: { headers: edit.headers } });
1390
1405
  if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
1406
+ if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
1391
1407
  return response;
1392
1408
  }
1393
1409
  ```
@@ -1441,18 +1457,124 @@ export function middleware(request: NextRequest) {
1441
1457
  `/api/draft` is the route handler shown under "Open a draft in your own site"
1442
1458
  above, reading `token` and `path`.
1443
1459
 
1444
- **Cookies in a frame.** The editor frames your site from another site, and a
1445
- browser only sends a cookie into a cross-site frame when it is
1446
- `SameSite=None; Secure`. Next sets the draft-mode cookie that way in a
1447
- production build. `next dev` sets it `SameSite=Lax`, so re-set it as
1448
- `SameSite=None; Secure` in your draft route while developing (browsers accept
1449
- `Secure` on `http://localhost`). Safari's third-party cookie blocking can still
1450
- drop it, which is why every preview load carries a fresh token and step 3
1451
- honours it on any page.
1452
-
1453
- **Let Capa frame the site.** Send
1454
- `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` (your
1455
- admin origin) so the editor can frame the site and nothing else can.
1460
+ **Cookies in a frame.** The editor frames your site from another site. A
1461
+ browser sends a cookie into a cross-site frame only when it is
1462
+ `SameSite=None; Secure`, and Safari 26.2 and later only when it is
1463
+ `Partitioned` too. Next sets the draft-mode cookie with neither `Partitioned`
1464
+ nor `Max-Age`, so it is dropped in Safari's frame and, everywhere else, opens
1465
+ every draft on the site until the browser closes. Pass Next's `cookies` to
1466
+ `createPreviewRoute` and `exitPreviewRoute` and they re-set it as
1467
+ `HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`, and
1468
+ delete it the same way. `maxAge` changes the hour. In your own route, call
1469
+ `frameDraftCookie(cookies)` after `enable()` and `clearDraftCookie(cookies)`
1470
+ after `disable()`. A browser that drops the cookie still gets a fresh token on
1471
+ every preview load, which step 3 honours on any page.
1472
+
1473
+ Leave `redirect` out of both routes. Each then answers with its own 307,
1474
+ marked `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` (the
1475
+ token is in the URL) and `Cache-Control: private, no-store`. Next's
1476
+ `redirect()` cannot carry headers, so passing it keeps the old redirect.
1477
+
1478
+ **Mark drafts, and let Capa frame them.** `capaHeaders()` returns rules for
1479
+ `next.config`'s `headers()` that send `X-Robots-Tag: noindex, nofollow` and
1480
+ `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` on a
1481
+ request carrying the draft cookie or a `capa-preview`, `capa-edit` or
1482
+ `capa-view` query, and on nothing else. A visitor's response, cached or not,
1483
+ is unchanged. Pass `adminOrigins` to name another admin, such as one running
1484
+ locally, and pass the same list to `createPreviewRoute`. A browser applies
1485
+ every CSP it is sent, so if your site sends its own `frame-ancestors` or
1486
+ `X-Frame-Options`, leave them off draft responses with
1487
+ `missing: [{ type: "cookie", key: DRAFT_COOKIE }]` on that rule.
1488
+ `capaMiddleware` marks its edit-mode responses noindex too, and
1489
+ `resolveEditRequest` returns the value as `robotsTag`.
1490
+
1491
+ ### Add preview to a live site without changing it
1492
+
1493
+ A live Next.js site that reads Capa with its own code keeps that code. Preview
1494
+ adds a branch that only draft mode switches on, and draft mode is off for
1495
+ every visitor, at build time and during ISR. Reading
1496
+ `(await draftMode()).isEnabled` leaves a static page static, so a visitor gets
1497
+ the same pages, headers and cache as before.
1498
+
1499
+ 1. **Read drafts in draft mode.** In the helper that fetches from Capa, before
1500
+ the existing fetch, read the same URL with a draft key and no cache. The
1501
+ data has the same shape, so every page renders unchanged.
1502
+
1503
+ ```ts
1504
+ // lib/capa-draft.ts
1505
+ import "server-only";
1506
+ import { draftMode } from "next/headers";
1507
+
1508
+ export async function isDraft(): Promise<boolean> {
1509
+ // draftMode() throws outside a request (generateStaticParams, some build steps).
1510
+ try { return (await draftMode()).isEnabled; } catch { return false; }
1511
+ }
1512
+
1513
+ // in the fetch helper, before the existing fetch, which stays as it is
1514
+ if (await isDraft()) {
1515
+ return fetch(`https://api.capacms.com/v2/api/${endpoint}${query}`, {
1516
+ headers: { "x-api-key": process.env.CAPA_DRAFT_KEY! },
1517
+ cache: "no-store",
1518
+ }).then(parse); // the helper's existing parse
1519
+ }
1520
+ ```
1521
+
1522
+ The draft key is any key of yours whose environment is not `production`
1523
+ (see Preview is a key, not a flag). Keep it server-only, never in a
1524
+ `NEXT_PUBLIC_` variable, and keep the branch in a `server-only` module: a
1525
+ helper a client component also imports would bundle it.
1526
+
1527
+ 2. **Add the preview and exit routes** with `createPreviewRoute({ draftMode,
1528
+ cookies })` and `exitPreviewRoute({ draftMode, cookies })`, as in the Quick
1529
+ start. Set `CAPA_API_URL` to `https://api.capacms.com` and `CAPA_KEY` to
1530
+ the production key the site already reads with, server-only: the routes
1531
+ check the editor's token with it.
1532
+
1533
+ 3. **Add the middleware behind a matcher**, so a visitor's request never runs
1534
+ it. Next reads `config` from the file itself, so write the matcher out:
1535
+
1536
+ ```ts
1537
+ // middleware.ts (proxy.ts on Next 16)
1538
+ import { NextResponse } from "next/server";
1539
+ import { capaMiddleware } from "@capacms/sdk/nextjs";
1540
+
1541
+ export const middleware = capaMiddleware({ NextResponse });
1542
+ export const config = {
1543
+ matcher: [
1544
+ { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
1545
+ { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
1546
+ { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
1547
+ { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
1548
+ ],
1549
+ };
1550
+ ```
1551
+
1552
+ 4. **Add `capaHeaders()`** to `next.config`'s `headers()`, as in the Quick
1553
+ start: drafts are marked noindex and only the Capa admin can frame them.
1554
+
1555
+ 5. **Start the overlay and tag fields in draft mode.** In the root layout:
1556
+
1557
+ ```tsx
1558
+ const draft = await isDraft();
1559
+ {draft ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
1560
+ ```
1561
+
1562
+ Tag an editable field with `{...capaAttrs({ id: entry.id }, "title", draft)}`.
1563
+ With `draft` false it returns `{}`, so a visitor's HTML gains no
1564
+ attribute. The field is its key in the entry, which is its namespace.
1565
+
1566
+ 6. **Route handlers that set their own `Cache-Control`** must send
1567
+ `private, no-store` in draft mode. Otherwise a CDN keeps a draft fetched by
1568
+ the editor's browser and serves it to everyone.
1569
+
1570
+ Do not call `editMode()`, `getCapaClient()` or `resolveEditRequest()` from a
1571
+ static or ISR page or layout. Each reads `headers()`, which makes every route
1572
+ that calls it dynamic: the HTML stays the same, but the site loses ISR and
1573
+ renders every view. `isDraft()` above is the static-safe check. The editor's
1574
+ Published view then shows the static page without the overlay.
1575
+
1576
+ In Capa, set the project's preview URL to the site's production origin, and
1577
+ give each model with a page a route pattern, or Preview has no link to open.
1456
1578
 
1457
1579
  ### The protocol
1458
1580
 
@@ -9,9 +9,9 @@ export interface CapaNextConfig {
9
9
  baseUrl: string;
10
10
  /**
11
11
  * A `cap_` key, or the legacy key a site already holds: `pk_`, `sk_`, or
12
- * an older key with no prefix. A legacy key reads through every read call
13
- * and warns once per process; `preview()` and draft reads take a `cap_` key
14
- * only.
12
+ * an older key with no prefix. A legacy key reads through every read call,
13
+ * `preview()` included, and warns once per process; the draft clients of
14
+ * `@capacms/sdk/nextjs` take a `cap_` key only.
15
15
  */
16
16
  apiKey: string;
17
17
  version: string;
@@ -210,7 +210,7 @@ export interface EntriesResource {
210
210
  * One suggestion drawn from a page's own reads.
211
211
  *
212
212
  * Computed on the API, never here. Three clients want this answer (this SDK,
213
- * the Capa admin and `@capa/mcp`), and a second implementation of "this page
213
+ * the Capa admin and `@capacms/mcp`), and a second implementation of "this page
214
214
  * over-fetches" would drift from the first the moment a threshold moved.
215
215
  */
216
216
  export interface PageInsight {
@@ -401,8 +401,8 @@ export interface CapaNextClient<Q = UntypedQuery, O extends GraphQLCallOptions =
401
401
  * Returns the claim, or NULL when the token is invalid or expired, because
402
402
  * both mean the same thing to a preview route: do not enable draft mode.
403
403
  * Every other failure throws, so a Capa outage does not look like a bad link.
404
- * Needs a `cap_` key: a client built with a legacy key throws a
405
- * `TypeError` here before any request.
404
+ * Takes any key the site holds, a legacy `pk_`, `sk_` or unprefixed key
405
+ * included: Capa answers a token minted for another tenant as invalid.
406
406
  */
407
407
  preview(token: string, options?: CallOptions): Promise<PreviewClaim | null>;
408
408
  me<T = Record<string, unknown>>(options?: CallOptions): Promise<T>;
@@ -501,7 +501,8 @@ function createClient(config) {
501
501
  return readSchema(options.signal);
502
502
  },
503
503
  async preview(token, options = {}) {
504
- (0, key_family_1.requirePreviewKey)(resolved.apiKey);
504
+ // Any key the site holds, legacy included: the API checks the token
505
+ // belongs to the key's own tenant (see key-family.ts).
505
506
  const query = new URLSearchParams();
506
507
  query.set("token", token);
507
508
  try {
@@ -28,7 +28,7 @@ exports.readPath = readPath;
28
28
  *
29
29
  * The API's own writer is `writeName` in `@capa/shared`. This package ships
30
30
  * with no runtime dependencies, so the rule is copied here and in
31
- * `@capa/mcp`, and test/fixtures/field-names.json pins all three to the same
31
+ * `@capacms/mcp`, and test/fixtures/field-names.json pins all three to the same
32
32
  * vectors.
33
33
  */
34
34
  const system_keys_1 = require("./system-keys");
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * One request carries both the standard introspection selection and the
6
6
  * `version` root field, so a schema summary always says which platform version
7
- * it describes. Kept byte-stable: `@capa/mcp` sends the same text, and a
7
+ * it describes. Kept byte-stable: `@capacms/mcp` sends the same text, and a
8
8
  * persisted copy of it is one hash for every client.
9
9
  *
10
10
  * Deprecated arguments and input fields are asked for too, with their
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * One request carries both the standard introspection selection and the
7
7
  * `version` root field, so a schema summary always says which platform version
8
- * it describes. Kept byte-stable: `@capa/mcp` sends the same text, and a
8
+ * it describes. Kept byte-stable: `@capacms/mcp` sends the same text, and a
9
9
  * persisted copy of it is one hash for every client.
10
10
  *
11
11
  * Deprecated arguments and input fields are asked for too, with their
@@ -80,7 +80,7 @@ function didYouMean(wanted, candidates) {
80
80
  /**
81
81
  * The refusal for a model the key reads that GraphQL leaves out (N1): REST
82
82
  * serves it, so the message names that read and why, and suggests no other
83
- * model. `@capa/mcp` says the same, pinned by a test.
83
+ * model. `@capacms/mcp` says the same, pinned by a test.
84
84
  */
85
85
  function restOnlyModel(namespace, restOnly) {
86
86
  // N1 leaves models out when their type names would collide, as these
@@ -176,7 +176,7 @@ function checkSort(value, model, where) {
176
176
  // A spec written as REST writes a read (`author.name` or `author(name)` for a
177
177
  // relation's fields, `*` for every field, `-publishedAt` for a sort) is
178
178
  // refused with what to write instead, spelled out, since the same read is one
179
- // edit away. @capa/mcp refuses them in the same words, pinned by
179
+ // edit away. @capacms/mcp refuses them in the same words, pinned by
180
180
  // test/fixtures/graphql-rest-forms.json.
181
181
  /** A REST sort key as a sort value: `-publishedAt` is `publishedAt_DESC`, `author.name` is `author__name_ASC`. */
182
182
  function restSortValue(value) {
@@ -2,16 +2,18 @@
2
2
  * key-family.ts — which keys `@capacms/sdk/next` takes, and for what.
3
3
  *
4
4
  * A `cap_` key is the `/api/` key: scoped, hashed at rest, and the only kind
5
- * a draft preview takes, both to verify a preview token and to read drafts.
6
- * Every other key is legacy, which is the API's rule
7
- * (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and `sk_` keys, and
8
- * the unprefixed keys older tenants were minted, all reach `/api/` with the
9
- * grants their permission gives. So every read call takes them, and the first
10
- * client built with one warns once per process, naming the key to mint.
5
+ * the draft clients take to read drafts. Every other key is legacy, which is
6
+ * the API's rule (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and
7
+ * `sk_` keys, and the unprefixed keys older tenants were minted, all reach
8
+ * `/api/` with the grants their permission gives. So every read call takes
9
+ * them, `preview()` included: `GET /api/preview` asks for `instance:read`,
10
+ * which every legacy permission grants, and refuses a token minted for another
11
+ * tenant. The first client built with one warns once per process, naming the
12
+ * key to mint.
11
13
  *
12
14
  * The `cap_` prefix is the whole rule, case-sensitive, as on the API:
13
15
  * `CAP_live_…` is not a key Capa mints, so it is looked up as a legacy key.
14
- * `@capa/mcp` applies the same rule, and both packages are tested against one
16
+ * `@capacms/mcp` applies the same rule, and both packages are tested against one
15
17
  * vector file, `test/fixtures/key-family.json`.
16
18
  */
17
19
  export type KeyFamily = "cap" | "legacy";
@@ -19,11 +21,6 @@ export type KeyFamily = "cap" | "legacy";
19
21
  export declare function keyFamily(apiKey: unknown): KeyFamily | null;
20
22
  /** Warn about a legacy key once per process: a site builds a client per request. */
21
23
  export declare function warnLegacyKeyOnce(apiKey: string): void;
22
- /**
23
- * Throws for a legacy key: verifying a preview token takes a `cap_` key. A
24
- * value that is no key at all is `resolveNextConfig`'s to refuse, by name.
25
- */
26
- export declare function requirePreviewKey(apiKey: string): void;
27
24
  /**
28
25
  * Throws for a legacy key: a draft read takes a `cap_` key. `holder` names
29
26
  * where the key came from (`CAPA_DRAFT_KEY`, `the draft config`), so the
@@ -3,22 +3,23 @@
3
3
  * key-family.ts — which keys `@capacms/sdk/next` takes, and for what.
4
4
  *
5
5
  * A `cap_` key is the `/api/` key: scoped, hashed at rest, and the only kind
6
- * a draft preview takes, both to verify a preview token and to read drafts.
7
- * Every other key is legacy, which is the API's rule
8
- * (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and `sk_` keys, and
9
- * the unprefixed keys older tenants were minted, all reach `/api/` with the
10
- * grants their permission gives. So every read call takes them, and the first
11
- * client built with one warns once per process, naming the key to mint.
6
+ * the draft clients take to read drafts. Every other key is legacy, which is
7
+ * the API's rule (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and
8
+ * `sk_` keys, and the unprefixed keys older tenants were minted, all reach
9
+ * `/api/` with the grants their permission gives. So every read call takes
10
+ * them, `preview()` included: `GET /api/preview` asks for `instance:read`,
11
+ * which every legacy permission grants, and refuses a token minted for another
12
+ * tenant. The first client built with one warns once per process, naming the
13
+ * key to mint.
12
14
  *
13
15
  * The `cap_` prefix is the whole rule, case-sensitive, as on the API:
14
16
  * `CAP_live_…` is not a key Capa mints, so it is looked up as a legacy key.
15
- * `@capa/mcp` applies the same rule, and both packages are tested against one
17
+ * `@capacms/mcp` applies the same rule, and both packages are tested against one
16
18
  * vector file, `test/fixtures/key-family.json`.
17
19
  */
18
20
  Object.defineProperty(exports, "__esModule", { value: true });
19
21
  exports.keyFamily = keyFamily;
20
22
  exports.warnLegacyKeyOnce = warnLegacyKeyOnce;
21
- exports.requirePreviewKey = requirePreviewKey;
22
23
  exports.requireDraftKey = requireDraftKey;
23
24
  exports.__resetKeyWarningForTests = __resetKeyWarningForTests;
24
25
  const LEGACY_PREFIX = /^(pk|sk)_/;
@@ -46,17 +47,8 @@ function warnLegacyKeyOnce(apiKey) {
46
47
  if (warned)
47
48
  return;
48
49
  warned = true;
49
- console.warn(`@capacms/sdk/next: this client reads with a ${legacyKind(apiKey)}. Reads work as they do with a cap_ key. ` +
50
- `Draft previews need a cap_ key: mint one ${MINT}`);
51
- }
52
- /**
53
- * Throws for a legacy key: verifying a preview token takes a `cap_` key. A
54
- * value that is no key at all is `resolveNextConfig`'s to refuse, by name.
55
- */
56
- function requirePreviewKey(apiKey) {
57
- if (!isLegacy(apiKey))
58
- return;
59
- throw new TypeError(`@capacms/sdk/next: preview needs a cap_ key, and this client holds a ${legacyKind(apiKey)}. Mint a cap_ key ${MINT}`);
50
+ console.warn(`@capacms/sdk/next: this client reads with a ${legacyKind(apiKey)}. Reads and preview links work as they do with a cap_ key. ` +
51
+ `Draft reads need a cap_ key: mint one ${MINT}`);
60
52
  }
61
53
  /**
62
54
  * Throws for a legacy key: a draft read takes a `cap_` key. `holder` names
@@ -111,6 +111,10 @@ export type { PreviewClaim };
111
111
  * clickable. It never switches the site to draft data.
112
112
  */
113
113
  export declare const EDIT_PARAM = "capa-edit";
114
+ /** The query parameter a Capa preview link carries: `?capa-preview=<token>`. */
115
+ export declare const PREVIEW_PARAM = "capa-preview";
116
+ /** The query parameter of the editor's Published view: `?capa-view=published`. */
117
+ export declare const VIEW_PARAM = "capa-view";
114
118
  /**
115
119
  * The request header `resolveEditRequest` sets once a `capa-edit` token has
116
120
  * been verified, for `editMode()` to read. Any copy a browser sent is removed
@@ -157,6 +161,8 @@ export interface EditRequest {
157
161
  headers: Headers;
158
162
  /** `EDIT_CACHE_CONTROL` in edit mode, otherwise null. Set it on the response. */
159
163
  cacheControl: string | null;
164
+ /** `DRAFT_ROBOTS_TAG` in edit mode, otherwise null. Set it on the response as `X-Robots-Tag`. */
165
+ robotsTag: string | null;
160
166
  }
161
167
  /**
162
168
  * The middleware half of edit mode.
@@ -165,6 +171,7 @@ export interface EditRequest {
165
171
  * const edit = await resolveEditRequest(request, publishedClient());
166
172
  * const response = NextResponse.next({ request: { headers: edit.headers } });
167
173
  * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
174
+ * if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
168
175
  * return response;
169
176
  * }
170
177
  *
@@ -323,29 +330,149 @@ export declare function graphql<D extends keyof CapaDocuments>(document: D, ...r
323
330
  export declare function graphql<TData = Record<string, unknown>, TVariables = Record<string, unknown>, D extends string = string>(document: NotARecordedDocument<D, TypedDocument<TData, TVariables> | D>, ...rest: VariablesThenOptions<TVariables, NextGraphQLOptions>): Promise<GraphQLResult<TData>>;
324
331
  /** Only a path on this site: never `//elsewhere.example` or a full URL. */
325
332
  export declare function safeSitePath(value: string | null | undefined): string;
333
+ /** The Capa admin's origin: what frames a draft in the editor, unless a site names another. */
334
+ export declare const CAPA_ADMIN_ORIGIN = "https://app.capacms.com";
335
+ /** `X-Robots-Tag` on every draft and edit-mode response: a draft is never indexed, nor its links followed. */
336
+ export declare const DRAFT_ROBOTS_TAG = "noindex, nofollow";
337
+ /** How long the draft cookie lasts, in seconds: one hour, as a preview token does. */
338
+ export declare const DRAFT_COOKIE_MAX_AGE = 3600;
339
+ /**
340
+ * `frame-ancestors 'self' <origins>`, the CSP directive that lets the Capa
341
+ * editor frame a draft and nothing else frame it. Each origin is a scheme and
342
+ * a host, with a port when it has one, and is written as the URL parser
343
+ * normalises it, so no `;` or quote can reach the policy. Anything else, a
344
+ * path or a query included, throws a `TypeError`.
345
+ */
346
+ export declare function frameAncestors(adminOrigins?: readonly string[]): string;
347
+ /**
348
+ * The headers every draft response carries: `X-Robots-Tag: noindex, nofollow`,
349
+ * and `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
350
+ * (or the `adminOrigins` given). Throws for an origin that is not one.
351
+ */
352
+ export declare function draftHeaders(options?: {
353
+ adminOrigins?: readonly string[];
354
+ }): Record<string, string>;
355
+ /** One rule of `next.config`'s `headers()`, in the shape Next takes. */
356
+ export interface NextHeaderRule {
357
+ source: string;
358
+ has: Array<{
359
+ type: "cookie" | "query";
360
+ key: string;
361
+ }>;
362
+ headers: Array<{
363
+ key: string;
364
+ value: string;
365
+ }>;
366
+ }
367
+ /**
368
+ * Rules for `next.config`'s `headers()` that send `draftHeaders` on draft
369
+ * responses only:
370
+ *
371
+ * // next.config.mjs
372
+ * import { capaHeaders } from "@capacms/sdk/nextjs";
373
+ * export default { async headers() { return [...capaHeaders()]; } };
374
+ *
375
+ * A rule matches a request carrying the draft cookie, or a `capa-preview`,
376
+ * `capa-edit` or `capa-view` query. A visitor's request carries none of
377
+ * them, so its response, cached or not, is exactly what it was. Next checks
378
+ * the cookie by name, not by value, so a forged cookie only adds these
379
+ * headers to the forger's own response.
380
+ *
381
+ * A site that sends its own `frame-ancestors` or `X-Frame-Options` must leave
382
+ * them off draft responses (`missing: [{ type: "cookie", key: DRAFT_COOKIE }]`
383
+ * on its rule): a browser applies every CSP it is sent, so the strictest wins.
384
+ */
385
+ export declare function capaHeaders(options?: {
386
+ adminOrigins?: readonly string[];
387
+ }): NextHeaderRule[];
388
+ /** The part of Next's `cookies()` store (`next/headers`) the draft-cookie helpers use. */
389
+ export interface DraftCookieJar {
390
+ get(name: string): {
391
+ value: string;
392
+ } | undefined;
393
+ set(cookie: {
394
+ name: string;
395
+ value: string;
396
+ path: string;
397
+ httpOnly: boolean;
398
+ secure: boolean;
399
+ sameSite: "none";
400
+ partitioned: boolean;
401
+ maxAge?: number;
402
+ expires?: Date;
403
+ }): unknown;
404
+ }
405
+ /** Next's `cookies` from `next/headers`, or anything shaped like it. */
406
+ export type CookiesFn = () => DraftCookieJar | Promise<DraftCookieJar>;
407
+ /**
408
+ * Re-set the cookie `draftMode().enable()` just set, so the Capa editor can
409
+ * use it: `HttpOnly; Secure; SameSite=None; Partitioned; Path=/` and
410
+ * `Max-Age` one hour (`maxAge` seconds). Next sets it with no `Max-Age`, so
411
+ * it unlocks every draft on the site until the browser closes, and without
412
+ * `Partitioned`, which Safari 26.2 and later need to send a cookie into a
413
+ * cross-site frame. Call it after `enable()`, in the same route handler.
414
+ * Resolves false, and sets nothing, when there is no draft cookie to re-set.
415
+ */
416
+ export declare function frameDraftCookie(cookies: CookiesFn, options?: {
417
+ maxAge?: number;
418
+ }): Promise<boolean>;
419
+ /**
420
+ * Delete the draft cookie `frameDraftCookie` set. A partitioned cookie is
421
+ * deleted only by a `Set-Cookie` that is partitioned too, which
422
+ * `draftMode().disable()` is not. Call it after `disable()`; it replaces
423
+ * `disable()`'s own deletion, since a response sets one cookie per name. A
424
+ * draft cookie set without `Partitioned`, before a site used
425
+ * `frameDraftCookie`, ends when the browser closes, as it always did.
426
+ */
427
+ export declare function clearDraftCookie(cookies: CookiesFn): Promise<void>;
326
428
  /**
327
429
  * `app/api/capa/preview/route.ts`:
328
430
  *
329
- * import { draftMode } from "next/headers";
330
- * import { redirect } from "next/navigation";
331
- * export const GET = createPreviewRoute({ draftMode, redirect });
431
+ * import { cookies, draftMode } from "next/headers";
432
+ * export const GET = createPreviewRoute({ draftMode, cookies });
332
433
  *
333
434
  * Checks the token with Capa (the site never holds the signing key), turns
334
435
  * draft mode on and lands on the entry's page. A bad or expired token lands on
335
436
  * the page without draft mode and `?preview=expired`; Capa unreachable gives
336
- * `?preview=unavailable`.
437
+ * `?preview=unavailable`. The token is checked with any key the site holds,
438
+ * its legacy key included (`getPublishedClient`, from `CAPA_KEY`).
439
+ *
440
+ * Given `cookies`, the draft cookie is re-set by `frameDraftCookie`: one hour,
441
+ * and sent inside the editor's frame. With no `redirect`, the route answers
442
+ * with its own 307, marked `X-Robots-Tag: noindex, nofollow`,
443
+ * `Referrer-Policy: no-referrer` (the token is in the URL),
444
+ * `Cache-Control: private, no-store` and the editor's `frame-ancestors`.
445
+ * Given Next's `redirect`, it is called instead, as before, and the redirect
446
+ * carries none of those.
337
447
  */
338
448
  export declare function createPreviewRoute(input: {
339
449
  draftMode: DraftModeFn;
340
- redirect: (url: string) => never | void;
450
+ /** Next's `cookies`. Given, the draft cookie lasts `maxAge` seconds and works in the editor's frame. */
451
+ cookies?: CookiesFn;
452
+ /** Next's `redirect`. Leave it out for a redirect that carries the draft headers. */
453
+ redirect?: (url: string) => never | void;
341
454
  client?: () => Pick<CapaNextClient, "preview">;
342
- /** Runs after draft mode is enabled, before the redirect (cookie tweaks). */
455
+ /** Runs after draft mode is enabled and the cookie re-set, before the redirect (cookie tweaks). */
343
456
  onEnable?: () => void | Promise<void>;
457
+ /** The origins allowed to frame a draft. `[CAPA_ADMIN_ORIGIN]` when left out. */
458
+ adminOrigins?: readonly string[];
459
+ /** Seconds the draft cookie lasts, given `cookies`. `DRAFT_COOKIE_MAX_AGE` (one hour) when left out. */
460
+ maxAge?: number;
344
461
  }): (request: Request) => Promise<Response | void>;
345
- /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
462
+ /**
463
+ * `app/api/capa/exit/route.ts`:
464
+ *
465
+ * import { cookies, draftMode } from "next/headers";
466
+ * export const GET = exitPreviewRoute({ draftMode, cookies });
467
+ *
468
+ * Turns draft mode off and lands on `?path=`. Given `cookies`, the framed
469
+ * cookie `createPreviewRoute` set is deleted too. With no `redirect`, the route
470
+ * answers with its own 307, marked noindex and never cached.
471
+ */
346
472
  export declare function exitPreviewRoute(input: {
347
473
  draftMode: DraftModeFn;
348
- redirect: (url: string) => never | void;
474
+ cookies?: CookiesFn;
475
+ redirect?: (url: string) => never | void;
349
476
  }): (request: Request) => Promise<Response | void>;
350
477
  interface MiddlewareRequest {
351
478
  url: string;
@@ -381,7 +508,20 @@ interface NextResponseLike {
381
508
  * - `?capa-view=published` renders without the draft cookie, for the editor's
382
509
  * Published view;
383
510
  * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
384
- * - every edit-mode response is `private, no-store`.
511
+ * - every edit-mode response is `private, no-store`, and it and every request
512
+ * carrying `capa-edit` or `capa-view` is `X-Robots-Tag: noindex, nofollow`.
513
+ *
514
+ * Give it a `matcher` so a visitor's request never runs it (Next reads
515
+ * `config` from the file itself, so it is written out there):
516
+ *
517
+ * export const config = {
518
+ * matcher: [
519
+ * { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
520
+ * { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
521
+ * { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
522
+ * { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
523
+ * ],
524
+ * };
385
525
  */
386
526
  export declare function capaMiddleware(input: {
387
527
  NextResponse: NextResponseLike;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_API_VERSION = exports.CAPA_ENV_ALIASES = exports.CAPA_ENV = exports.EDIT_CACHE_CONTROL = exports.DRAFT_COOKIE = exports.EDIT_HEADER = exports.EDIT_PARAM = exports.LAYOUT_PAGE = exports.gql = exports.MEDIA_TAG = exports.GRAPHQL_TAG = void 0;
3
+ exports.DRAFT_COOKIE_MAX_AGE = exports.DRAFT_ROBOTS_TAG = exports.CAPA_ADMIN_ORIGIN = exports.DEFAULT_API_VERSION = exports.CAPA_ENV_ALIASES = exports.CAPA_ENV = exports.EDIT_CACHE_CONTROL = exports.DRAFT_COOKIE = exports.EDIT_HEADER = exports.VIEW_PARAM = exports.PREVIEW_PARAM = exports.EDIT_PARAM = exports.LAYOUT_PAGE = exports.gql = exports.MEDIA_TAG = exports.GRAPHQL_TAG = void 0;
4
4
  exports.withCache = withCache;
5
5
  exports.modelTag = modelTag;
6
6
  exports.tagsFor = tagsFor;
@@ -15,6 +15,11 @@ exports.getPublishedClient = getPublishedClient;
15
15
  exports.getCapaClient = getCapaClient;
16
16
  exports.graphql = graphql;
17
17
  exports.safeSitePath = safeSitePath;
18
+ exports.frameAncestors = frameAncestors;
19
+ exports.draftHeaders = draftHeaders;
20
+ exports.capaHeaders = capaHeaders;
21
+ exports.frameDraftCookie = frameDraftCookie;
22
+ exports.clearDraftCookie = clearDraftCookie;
18
23
  exports.createPreviewRoute = createPreviewRoute;
19
24
  exports.exitPreviewRoute = exitPreviewRoute;
20
25
  exports.capaMiddleware = capaMiddleware;
@@ -284,6 +289,10 @@ function pagesFor(client) {
284
289
  * clickable. It never switches the site to draft data.
285
290
  */
286
291
  exports.EDIT_PARAM = "capa-edit";
292
+ /** The query parameter a Capa preview link carries: `?capa-preview=<token>`. */
293
+ exports.PREVIEW_PARAM = "capa-preview";
294
+ /** The query parameter of the editor's Published view: `?capa-view=published`. */
295
+ exports.VIEW_PARAM = "capa-view";
287
296
  /**
288
297
  * The request header `resolveEditRequest` sets once a `capa-edit` token has
289
298
  * been verified, for `editMode()` to read. Any copy a browser sent is removed
@@ -322,6 +331,7 @@ function verifiedEdit(headers) {
322
331
  * const edit = await resolveEditRequest(request, publishedClient());
323
332
  * const response = NextResponse.next({ request: { headers: edit.headers } });
324
333
  * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
334
+ * if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
325
335
  * return response;
326
336
  * }
327
337
  *
@@ -348,7 +358,13 @@ async function resolveEditRequest(request, client) {
348
358
  const draft = request.cookies?.has(exports.DRAFT_COOKIE) ??
349
359
  (headers.get("cookie") ?? "").split(/;\s*/).some((c) => c.startsWith(`${exports.DRAFT_COOKIE}=`));
350
360
  const edit = verified || draft;
351
- return { edit, verified, headers, cacheControl: edit ? exports.EDIT_CACHE_CONTROL : null };
361
+ return {
362
+ edit,
363
+ verified,
364
+ headers,
365
+ cacheControl: edit ? exports.EDIT_CACHE_CONTROL : null,
366
+ robotsTag: edit ? exports.DRAFT_ROBOTS_TAG : null,
367
+ };
352
368
  }
353
369
  // ------------------------------------------------- five-minute integration ---
354
370
  /**
@@ -570,26 +586,166 @@ function safeSitePath(value) {
570
586
  return "/";
571
587
  return value;
572
588
  }
589
+ // -------------------------------------------------------- draft responses ---
590
+ /** The Capa admin's origin: what frames a draft in the editor, unless a site names another. */
591
+ exports.CAPA_ADMIN_ORIGIN = "https://app.capacms.com";
592
+ /** `X-Robots-Tag` on every draft and edit-mode response: a draft is never indexed, nor its links followed. */
593
+ exports.DRAFT_ROBOTS_TAG = "noindex, nofollow";
594
+ /** How long the draft cookie lasts, in seconds: one hour, as a preview token does. */
595
+ exports.DRAFT_COOKIE_MAX_AGE = 3600;
596
+ /**
597
+ * `frame-ancestors 'self' <origins>`, the CSP directive that lets the Capa
598
+ * editor frame a draft and nothing else frame it. Each origin is a scheme and
599
+ * a host, with a port when it has one, and is written as the URL parser
600
+ * normalises it, so no `;` or quote can reach the policy. Anything else, a
601
+ * path or a query included, throws a `TypeError`.
602
+ */
603
+ function frameAncestors(adminOrigins = [exports.CAPA_ADMIN_ORIGIN]) {
604
+ const origins = new Set();
605
+ for (const value of adminOrigins)
606
+ origins.add(originOf(value));
607
+ return ["frame-ancestors 'self'", ...origins].join(" ");
608
+ }
609
+ function originOf(value) {
610
+ let url = null;
611
+ try {
612
+ url = typeof value === "string" ? new URL(value) : null;
613
+ }
614
+ catch {
615
+ url = null;
616
+ }
617
+ const bare = url !== null &&
618
+ (url.protocol === "https:" || url.protocol === "http:") &&
619
+ url.pathname === "/" &&
620
+ url.search === "" &&
621
+ url.hash === "" &&
622
+ url.username === "" &&
623
+ url.password === "";
624
+ if (!bare) {
625
+ throw new TypeError(`@capacms/sdk/nextjs: ${JSON.stringify(value)} is not an origin. Pass a scheme and a host, such as ${exports.CAPA_ADMIN_ORIGIN}.`);
626
+ }
627
+ return url.origin;
628
+ }
629
+ /**
630
+ * The headers every draft response carries: `X-Robots-Tag: noindex, nofollow`,
631
+ * and `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
632
+ * (or the `adminOrigins` given). Throws for an origin that is not one.
633
+ */
634
+ function draftHeaders(options = {}) {
635
+ return {
636
+ "X-Robots-Tag": exports.DRAFT_ROBOTS_TAG,
637
+ "Content-Security-Policy": frameAncestors(options.adminOrigins),
638
+ };
639
+ }
640
+ /**
641
+ * Rules for `next.config`'s `headers()` that send `draftHeaders` on draft
642
+ * responses only:
643
+ *
644
+ * // next.config.mjs
645
+ * import { capaHeaders } from "@capacms/sdk/nextjs";
646
+ * export default { async headers() { return [...capaHeaders()]; } };
647
+ *
648
+ * A rule matches a request carrying the draft cookie, or a `capa-preview`,
649
+ * `capa-edit` or `capa-view` query. A visitor's request carries none of
650
+ * them, so its response, cached or not, is exactly what it was. Next checks
651
+ * the cookie by name, not by value, so a forged cookie only adds these
652
+ * headers to the forger's own response.
653
+ *
654
+ * A site that sends its own `frame-ancestors` or `X-Frame-Options` must leave
655
+ * them off draft responses (`missing: [{ type: "cookie", key: DRAFT_COOKIE }]`
656
+ * on its rule): a browser applies every CSP it is sent, so the strictest wins.
657
+ */
658
+ function capaHeaders(options = {}) {
659
+ const headers = () => Object.entries(draftHeaders(options)).map(([key, value]) => ({ key, value }));
660
+ const conditions = [
661
+ { type: "cookie", key: exports.DRAFT_COOKIE },
662
+ { type: "query", key: exports.PREVIEW_PARAM },
663
+ { type: "query", key: exports.EDIT_PARAM },
664
+ { type: "query", key: exports.VIEW_PARAM },
665
+ ];
666
+ return conditions.map((condition) => ({ source: "/:path*", has: [condition], headers: headers() }));
667
+ }
668
+ /** The attributes a draft cookie needs to be sent inside the Capa editor's cross-site frame. */
669
+ const FRAMED = { path: "/", httpOnly: true, secure: true, sameSite: "none", partitioned: true };
670
+ function draftCookieMaxAge(maxAge = exports.DRAFT_COOKIE_MAX_AGE) {
671
+ if (typeof maxAge !== "number" || !Number.isInteger(maxAge) || maxAge <= 0) {
672
+ throw new TypeError(`@capacms/sdk/nextjs: maxAge must be a whole number of seconds above 0, and got ${String(maxAge)}.`);
673
+ }
674
+ return maxAge;
675
+ }
676
+ /**
677
+ * Re-set the cookie `draftMode().enable()` just set, so the Capa editor can
678
+ * use it: `HttpOnly; Secure; SameSite=None; Partitioned; Path=/` and
679
+ * `Max-Age` one hour (`maxAge` seconds). Next sets it with no `Max-Age`, so
680
+ * it unlocks every draft on the site until the browser closes, and without
681
+ * `Partitioned`, which Safari 26.2 and later need to send a cookie into a
682
+ * cross-site frame. Call it after `enable()`, in the same route handler.
683
+ * Resolves false, and sets nothing, when there is no draft cookie to re-set.
684
+ */
685
+ async function frameDraftCookie(cookies, options = {}) {
686
+ const maxAge = draftCookieMaxAge(options.maxAge);
687
+ const jar = await cookies();
688
+ const current = jar.get(exports.DRAFT_COOKIE);
689
+ if (!current?.value)
690
+ return false;
691
+ jar.set({ name: exports.DRAFT_COOKIE, value: current.value, ...FRAMED, maxAge });
692
+ return true;
693
+ }
694
+ /**
695
+ * Delete the draft cookie `frameDraftCookie` set. A partitioned cookie is
696
+ * deleted only by a `Set-Cookie` that is partitioned too, which
697
+ * `draftMode().disable()` is not. Call it after `disable()`; it replaces
698
+ * `disable()`'s own deletion, since a response sets one cookie per name. A
699
+ * draft cookie set without `Partitioned`, before a site used
700
+ * `frameDraftCookie`, ends when the browser closes, as it always did.
701
+ */
702
+ async function clearDraftCookie(cookies) {
703
+ (await cookies()).set({ name: exports.DRAFT_COOKIE, value: "", ...FRAMED, expires: new Date(0) });
704
+ }
705
+ /**
706
+ * The preview and exit routes' own redirect. Next's `redirect()` throws and
707
+ * answers for the route, so it cannot carry headers; this one is a plain
708
+ * `Response`, to which Next appends every cookie the route set. It sets no
709
+ * cookie itself: Next keeps one per name, and the response's own would win.
710
+ */
711
+ function routeRedirect(location, headers) {
712
+ return new Response(null, {
713
+ status: 307,
714
+ headers: { Location: location, "Cache-Control": exports.EDIT_CACHE_CONTROL, "Referrer-Policy": "no-referrer", ...headers },
715
+ });
716
+ }
573
717
  /**
574
718
  * `app/api/capa/preview/route.ts`:
575
719
  *
576
- * import { draftMode } from "next/headers";
577
- * import { redirect } from "next/navigation";
578
- * export const GET = createPreviewRoute({ draftMode, redirect });
720
+ * import { cookies, draftMode } from "next/headers";
721
+ * export const GET = createPreviewRoute({ draftMode, cookies });
579
722
  *
580
723
  * Checks the token with Capa (the site never holds the signing key), turns
581
724
  * draft mode on and lands on the entry's page. A bad or expired token lands on
582
725
  * the page without draft mode and `?preview=expired`; Capa unreachable gives
583
- * `?preview=unavailable`.
726
+ * `?preview=unavailable`. The token is checked with any key the site holds,
727
+ * its legacy key included (`getPublishedClient`, from `CAPA_KEY`).
728
+ *
729
+ * Given `cookies`, the draft cookie is re-set by `frameDraftCookie`: one hour,
730
+ * and sent inside the editor's frame. With no `redirect`, the route answers
731
+ * with its own 307, marked `X-Robots-Tag: noindex, nofollow`,
732
+ * `Referrer-Policy: no-referrer` (the token is in the URL),
733
+ * `Cache-Control: private, no-store` and the editor's `frame-ancestors`.
734
+ * Given Next's `redirect`, it is called instead, as before, and the redirect
735
+ * carries none of those.
584
736
  */
585
737
  function createPreviewRoute(input) {
738
+ // Checked here, so a bad origin or maxAge fails where the route is built.
739
+ const headers = draftHeaders({ adminOrigins: input.adminOrigins });
740
+ const maxAge = draftCookieMaxAge(input.maxAge);
741
+ const go = (location) => input.redirect ? input.redirect(location) : routeRedirect(location, headers);
586
742
  return async (request) => {
587
743
  const url = new URL(request.url);
588
- const token = url.searchParams.get("token") ?? url.searchParams.get("capa-preview") ?? "";
744
+ const token = url.searchParams.get("token") ?? url.searchParams.get(exports.PREVIEW_PARAM) ?? "";
589
745
  // Reached two ways: directly, or through the middleware's rewrite of a page
590
746
  // URL carrying `?capa-preview=`, where Next may hand over the ORIGINAL URL.
591
747
  // Then the page itself is the path.
592
- const path = safeSitePath(url.searchParams.get("path") ?? (url.searchParams.has("capa-preview") ? url.pathname : null));
748
+ const path = safeSitePath(url.searchParams.get("path") ?? (url.searchParams.has(exports.PREVIEW_PARAM) ? url.pathname : null));
593
749
  let claim = null;
594
750
  let failed = false;
595
751
  try {
@@ -601,18 +757,36 @@ function createPreviewRoute(input) {
601
757
  const draft = await input.draftMode();
602
758
  if (!claim) {
603
759
  draft.disable?.();
604
- return input.redirect(`${path}?preview=${failed ? "unavailable" : "expired"}`);
760
+ if (input.cookies)
761
+ await clearDraftCookie(input.cookies);
762
+ return go(`${path}?preview=${failed ? "unavailable" : "expired"}`);
605
763
  }
606
764
  draft.enable?.();
765
+ if (input.cookies)
766
+ await frameDraftCookie(input.cookies, { maxAge });
607
767
  await input.onEnable?.();
608
- return input.redirect(safeSitePath(claim.path ?? path));
768
+ return go(safeSitePath(claim.path ?? path));
609
769
  };
610
770
  }
611
- /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
771
+ /**
772
+ * `app/api/capa/exit/route.ts`:
773
+ *
774
+ * import { cookies, draftMode } from "next/headers";
775
+ * export const GET = exitPreviewRoute({ draftMode, cookies });
776
+ *
777
+ * Turns draft mode off and lands on `?path=`. Given `cookies`, the framed
778
+ * cookie `createPreviewRoute` set is deleted too. With no `redirect`, the route
779
+ * answers with its own 307, marked noindex and never cached.
780
+ */
612
781
  function exitPreviewRoute(input) {
613
782
  return async (request) => {
614
783
  (await input.draftMode()).disable?.();
615
- return input.redirect(safeSitePath(new URL(request.url).searchParams.get("path")));
784
+ if (input.cookies)
785
+ await clearDraftCookie(input.cookies);
786
+ const location = safeSitePath(new URL(request.url).searchParams.get("path"));
787
+ if (input.redirect)
788
+ return input.redirect(location);
789
+ return routeRedirect(location, { "X-Robots-Tag": exports.DRAFT_ROBOTS_TAG });
616
790
  };
617
791
  }
618
792
  /**
@@ -626,12 +800,25 @@ function exitPreviewRoute(input) {
626
800
  * - `?capa-view=published` renders without the draft cookie, for the editor's
627
801
  * Published view;
628
802
  * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
629
- * - every edit-mode response is `private, no-store`.
803
+ * - every edit-mode response is `private, no-store`, and it and every request
804
+ * carrying `capa-edit` or `capa-view` is `X-Robots-Tag: noindex, nofollow`.
805
+ *
806
+ * Give it a `matcher` so a visitor's request never runs it (Next reads
807
+ * `config` from the file itself, so it is written out there):
808
+ *
809
+ * export const config = {
810
+ * matcher: [
811
+ * { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
812
+ * { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
813
+ * { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
814
+ * { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
815
+ * ],
816
+ * };
630
817
  */
631
818
  function capaMiddleware(input) {
632
819
  const previewRoute = input.previewRoute ?? "/api/capa/preview";
633
820
  return async (request) => {
634
- const token = request.nextUrl.searchParams.get("capa-preview");
821
+ const token = request.nextUrl.searchParams.get(exports.PREVIEW_PARAM);
635
822
  if (token) {
636
823
  const target = request.nextUrl.clone();
637
824
  target.pathname = previewRoute;
@@ -641,7 +828,7 @@ function capaMiddleware(input) {
641
828
  return input.NextResponse.rewrite(target);
642
829
  }
643
830
  const headers = new Headers(request.headers);
644
- if (request.nextUrl.searchParams.get("capa-view") === "published") {
831
+ if (request.nextUrl.searchParams.get(exports.VIEW_PARAM) === "published") {
645
832
  const cookies = request.cookies
646
833
  .getAll()
647
834
  .filter((cookie) => cookie.name !== exports.DRAFT_COOKIE)
@@ -658,6 +845,11 @@ function capaMiddleware(input) {
658
845
  const response = input.NextResponse.next({ request: { headers: edit.headers } });
659
846
  if (edit.cacheControl)
660
847
  response.headers.set("Cache-Control", edit.cacheControl);
848
+ // A capa- link is the editor's, verified or not, and never a page to index.
849
+ const capaLink = request.nextUrl.searchParams.has(exports.EDIT_PARAM) || request.nextUrl.searchParams.has(exports.VIEW_PARAM);
850
+ const robots = edit.robotsTag ?? (capaLink ? exports.DRAFT_ROBOTS_TAG : null);
851
+ if (robots)
852
+ response.headers.set("X-Robots-Tag", robots);
661
853
  return response;
662
854
  };
663
855
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capacms/sdk",
3
- "version": "1.0.0-next.7",
3
+ "version": "1.0.0-next.8",
4
4
  "description": "The TypeScript SDK for Capa's content API: REST and GraphQL reads typed from your models, Next.js caching and live preview, and codegen.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://docs.capacms.com/api",
@@ -89,6 +89,6 @@
89
89
  "scripts": {
90
90
  "build": "tsc -p tsconfig.json",
91
91
  "typecheck": "tsc -p tsconfig.json --noEmit",
92
- "test": "tsc -p tsconfig.json && node --test test/comments.test.js test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js test/attrs.test.js test/overlay.test.js test/inflate.test.js test/field-names.test.js test/next-graphql.test.js test/graphql-codegen.test.js test/graphql-contract.test.js test/readme-snippets.test.js test/nextjs-app.test.js test/package-json.test.js test/changelog.test.js test/published-docs.test.js test/builder-messages.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit && tsc -p test/types/tsconfig.nonstrict.json --noEmit"
92
+ "test": "tsc -p tsconfig.json && node --test test/comments.test.js test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js test/attrs.test.js test/overlay.test.js test/inflate.test.js test/field-names.test.js test/next-graphql.test.js test/graphql-codegen.test.js test/graphql-contract.test.js test/readme-snippets.test.js test/nextjs-app.test.js test/nextjs-preview-app.test.js test/package-json.test.js test/changelog.test.js test/published-docs.test.js test/builder-messages.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit && tsc -p test/types/tsconfig.nonstrict.json --noEmit && tsc -p test/types/tsconfig.next.json --noEmit"
93
93
  }
94
94
  }