@capacms/sdk 1.0.0-next.6 → 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,60 @@
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.
37
+ - 1.0.0-next.7. Browsers: the `/api/` and legacy clients called the platform `fetch` as a
38
+ method of their config, which a browser refuses ("Failed to execute 'fetch'
39
+ on 'Window': Illegal invocation"), so every read from a browser failed unless
40
+ `fetch` was passed in. The default fetch is now called through `globalThis`
41
+ on each request, which also picks up a fetch a framework patches in later.
42
+ - `next` is no longer a peer dependency. No range matches every Next canary,
43
+ so a site on `next@16.3.0-canary.39` could not `npm install` the SDK without
44
+ `--legacy-peer-deps`. `@capacms/sdk/nextjs/overlay` still imports `next`,
45
+ which only a Next site loads.
46
+ - Docs: the edit mark is dropped wherever an entry is serialized to the
47
+ browser (Pages Router props, SvelteKit and Remix loaders, Nuxt payload);
48
+ pass the edit flag to `capaAttrs` there. Found testing preview on nine stacks.
49
+ - Next.js: a GraphQL read that gives neither `tags` nor `revalidate`, through
50
+ `getCapaClient().graphql()`, its builder or `graphql()`, is no longer kept
51
+ in Next's data cache. It is sent as `entries.list` and `entries.get` send
52
+ theirs, with no `cache` and no `next`, so a publish shows on the next render
53
+ whether the page reads by REST or by GraphQL. It used to be kept under
54
+ `capa:graphql` until a webhook revalidated it, so a site with no webhook
55
+ route kept serving the old answer by GraphQL while REST showed the new one.
56
+ A read that gives `tags` or a `revalidate` is kept as before. To keep a read
57
+ with no tags as it was kept, pass `revalidate: false`, which keeps it under
58
+ `capa:graphql` until the next publish. `tags: []` now counts as no tags.
5
59
  - 1.0.0-next.6. GraphQL, on `@capacms/sdk/next` and `@capacms/sdk/nextjs`,
6
60
  and two commands, `capa-codegen --graphql` and `capa persist`. Additive:
7
61
  every call that existed behaves as before. `graphql` is an optional peer
@@ -143,7 +197,7 @@
143
197
  as it takes a tool spec.
144
198
 
145
199
  The tool spec. `buildGraphQLQuery`, `graphqlToSelect` and `selectToGraphQL`
146
- 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
147
201
  the equal REST request, written exactly as the API writes it in
148
202
  `extensions.capa.rest`: the filter as `where` JSON, and a system key a
149
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
@@ -287,9 +289,10 @@ const capa = await draftClient<CapaQuery>({
287
289
  `CapaQuery` types the GraphQL builder on the client it returns, as it does
288
290
  for `createClient<CapaQuery>`; `getCapaClient<CapaQuery>` and
289
291
  `getPublishedClient<CapaQuery>` take it the same way. Without it each helper
290
- returns an untyped client. `getCapaClient` also keeps its GraphQL reads in
291
- Next's data cache, as `graphql()` does, under the `tags` and `revalidate`
292
- each call gives (see The typed builder).
292
+ returns an untyped client. `getCapaClient` sends its GraphQL reads as it
293
+ sends its REST reads, which Next does not keep, unless a call gives `tags` or
294
+ `revalidate`; then it keeps them in Next's data cache as `graphql()` does
295
+ (see The typed builder).
293
296
 
294
297
  ## GraphQL
295
298
 
@@ -335,8 +338,10 @@ once `capa-codegen --graphql` has read it (see Typed documents). Until then
335
338
  the call does not compile, and the error says to run it, so a document edited
336
339
  since the last run is never silently untyped.
337
340
 
338
- `graphql()` keeps a published read in Next's data cache, through Next's own
339
- `unstable_cache`, under the tags you give. `BlogIndexModels` lists the models
341
+ Given `tags` or `revalidate`, `graphql()` keeps a published read in Next's
342
+ data cache, through Next's own `unstable_cache`, under the tags you give.
343
+ Given neither, it keeps nothing: the read is sent as a REST read is, and the
344
+ page shows a publish on its next render. `BlogIndexModels` lists the models
340
345
  the query reads, which codegen writes beside its types: `articles`, and
341
346
  `authors` for `author { name }`. It changes when the query does, so a model
342
347
  the query starts reading is never left out. `tagsFor({ namespace })` makes
@@ -348,16 +353,19 @@ library, its alt text say, changes no entry, so it revalidates `capa:media`
348
353
  instead, and every read tagged by its models shows the new text. How long a
349
354
  read is kept:
350
355
 
351
- - With no `revalidate`, until one of its tags is revalidated. With the
352
- webhook route, that is until the next publish of a model it reads.
356
+ - With neither `tags` nor `revalidate`, not at all: every render reads the
357
+ API, as `entries.list` does, so a site with no webhook route still shows a
358
+ publish on the next request.
359
+ - With `tags` and no `revalidate`, until one of its tags is revalidated.
360
+ With the webhook route, that is until the next publish of a model it reads.
353
361
  - With `revalidate: 60`, also at most 60 seconds, so a missed webhook costs
354
- a minute of stale content at most. This is the only thing it adds.
355
- - With `revalidate: 0`, not at all: every render reads the API.
362
+ a minute of stale content at most.
363
+ - With `revalidate: 0`, not at all.
356
364
 
357
- A read with no `tags` is tagged `capa:graphql` (`GRAPHQL_TAG`), which
358
- `revalidateFromWebhook` revalidates on every content change, so it is never
359
- stale after a publish; tag it with its models to refresh the page only when
360
- one of them changes.
365
+ A read with a `revalidate` and no `tags` is tagged `capa:graphql`
366
+ (`GRAPHQL_TAG`), which `revalidateFromWebhook` revalidates on every content
367
+ change, so it is never stale after a publish; tag it with its models to
368
+ refresh the page only when one of them changes.
361
369
 
362
370
  A read that answered with `errors` is never kept: a root field that timed
363
371
  out is shown once and read again on the next request. Next's fetch cache is
@@ -513,10 +521,10 @@ it is never cached. The admin host serves GraphQL by POST only and answers a
513
521
  GET as a path it does not serve; a read that did not ask for GET is then
514
522
  repeated as a POST, and that host is read by POST for five minutes. A
515
523
  `method: "GET"` read there throws "This Capa host does not serve GraphQL by
516
- GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top,
517
- for a published read with no errors, a POST included: kept under its `tags`
518
- until one is revalidated, or for `revalidate` seconds when you give it, and
519
- never for a draft.
524
+ GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top
525
+ when a read gives `tags` or `revalidate`, for a published read with no
526
+ errors, a POST included: kept under its `tags` until one is revalidated, or
527
+ for `revalidate` seconds when you give it, and never for a draft.
520
528
 
521
529
  ### The typed builder
522
530
 
@@ -557,8 +565,8 @@ document is printed when it runs, so `capa persist` never stored it, and it
557
565
  is already a GET that the API and the CDN cache. To persist a read, write it
558
566
  as a `#graphql` literal (see Persisted queries).
559
567
 
560
- In a Next.js server component, read through `getCapaClient`. Its reads are
561
- kept in Next's data cache, under the tags each call names:
568
+ In a Next.js server component, read through `getCapaClient`. A read that
569
+ names tags is kept in Next's data cache under them:
562
570
 
563
571
  ```tsx
564
572
  // app/blog/page.tsx
@@ -579,7 +587,8 @@ export default async function Blog() {
579
587
  `tags` and `revalidate` work as they do for `graphql()` (see In a Next.js
580
588
  server component). A read is kept until a publish of a model its tags name,
581
589
  a read with errors is never kept, and a draft or an edit-mode page is read
582
- uncached. `capa.graphql(document)` on the same client takes them too.
590
+ uncached. A read with neither is not kept, as the client's REST reads are
591
+ not. `capa.graphql(document)` on the same client takes them too.
583
592
 
584
593
  Read one field twice in one request with an alias: any other key, with
585
594
  `__aliasFor` naming the field it reads. A home page's featured and latest
@@ -1266,8 +1275,9 @@ editor sees the path without a link to open it.
1266
1275
 
1267
1276
  ### Quick start (Next.js)
1268
1277
 
1269
- Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`) and `CAPA_DRAFT_KEY` (`cap_test_`).
1270
- 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:
1271
1281
 
1272
1282
  ```ts
1273
1283
  // middleware.ts
@@ -1276,13 +1286,17 @@ import { capaMiddleware } from "@capacms/sdk/nextjs";
1276
1286
  export const middleware = capaMiddleware({ NextResponse });
1277
1287
 
1278
1288
  // app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute)
1279
- import { draftMode } from "next/headers";
1280
- import { redirect } from "next/navigation";
1289
+ import { cookies, draftMode } from "next/headers";
1281
1290
  import { createPreviewRoute } from "@capacms/sdk/nextjs";
1282
- 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(); } };
1283
1296
 
1284
1297
  // in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
1285
- // in the root layout, in edit mode: <CapaOverlay adminOrigins={[...]} /> from @capacms/sdk/nextjs/overlay
1298
+ // in the root layout: const edit = await editMode({ draftMode, headers }) from @capacms/sdk/nextjs,
1299
+ // then {edit ? <CapaOverlay adminOrigins={[...]} /> : null} from @capacms/sdk/nextjs/overlay
1286
1300
  ```
1287
1301
 
1288
1302
  In Capa, set the project's preview URL to your site, and editors can click
@@ -1330,6 +1344,33 @@ The mark does not survive a spread copy or being passed to a client component,
1330
1344
  so tag in the server component that read the entry. `capaAttrs(entry, field,
1331
1345
  true)` still forces the tags on and `false` forces them off.
1332
1346
 
1347
+ **When the entry crosses to the browser as data, pass the flag.** The mark is
1348
+ a hidden symbol, so anything that serializes the entry drops it silently: the
1349
+ Next Pages Router's `getServerSideProps`, SvelteKit and Remix loaders, Nuxt's
1350
+ payload, or your own JSON endpoint. The page then shows the draft with no
1351
+ `data-capa-` tags, and the editor has nothing to point at. Nothing errors. Send
1352
+ the draft or edit state alongside the entry and hand it to `capaAttrs`:
1353
+
1354
+ ```tsx
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
+ }
1364
+
1365
+ // in the component
1366
+ export default function Page({ entry, edit }: { entry: Entry<Articles>; edit: boolean }) {
1367
+ return <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>;
1368
+ }
1369
+ ```
1370
+
1371
+ Or re-mark the entries where they arrive with `markEditEntries(data)` when the
1372
+ page is in edit mode. Tested on the Pages Router, SvelteKit and Nuxt.
1373
+
1333
1374
  GraphQL reads are marked the same way, from `getCapaClient`, an edit-mode
1334
1375
  `createClient` or `graphql()` given `{ draftMode, headers }`. A node is an
1335
1376
  entry when it selected `id` and `model`, and `field` is the field as you
@@ -1362,6 +1403,7 @@ export async function middleware(request: NextRequest) {
1362
1403
  const edit = await resolveEditRequest(request, publishedClient());
1363
1404
  const response = NextResponse.next({ request: { headers: edit.headers } });
1364
1405
  if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
1406
+ if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
1365
1407
  return response;
1366
1408
  }
1367
1409
  ```
@@ -1415,18 +1457,124 @@ export function middleware(request: NextRequest) {
1415
1457
  `/api/draft` is the route handler shown under "Open a draft in your own site"
1416
1458
  above, reading `token` and `path`.
1417
1459
 
1418
- **Cookies in a frame.** The editor frames your site from another site, and a
1419
- browser only sends a cookie into a cross-site frame when it is
1420
- `SameSite=None; Secure`. Next sets the draft-mode cookie that way in a
1421
- production build. `next dev` sets it `SameSite=Lax`, so re-set it as
1422
- `SameSite=None; Secure` in your draft route while developing (browsers accept
1423
- `Secure` on `http://localhost`). Safari's third-party cookie blocking can still
1424
- drop it, which is why every preview load carries a fresh token and step 3
1425
- honours it on any page.
1426
-
1427
- **Let Capa frame the site.** Send
1428
- `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` (your
1429
- 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.
1430
1578
 
1431
1579
  ### The protocol
1432
1580
 
package/dist/config.d.ts CHANGED
@@ -1,38 +1,3 @@
1
- /**
2
- * Client configuration, and the one thing about it that is not obvious.
3
- *
4
- * PREVIEW IS A KEY, NOT A FLAG
5
- * Capa gates unpublished content on the API key's `environment` column, not on
6
- * anything in the request:
7
- *
8
- * // apps/api/src/routes/v2/api.ts:203
9
- * const environment = req.apiKeyEnvironment || "production";
10
- * const includeDrafted = environment === "production" ? false : true;
11
- *
12
- * So there is no `?preview=true` to pass, and `getContent(ns, id, {preview})`
13
- * CANNOT be implemented against a published key — the server would ignore it.
14
- * The honest surface is one client per key:
15
- *
16
- * const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY })
17
- * const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY })
18
- *
19
- * Note also that the comparison above is exact and case-sensitive against a
20
- * free-text column, so ANY environment that is not literally "production"
21
- * returns drafts — `staging`, `development`, `draft`, and equally a typo like
22
- * "Production".
23
- *
24
- * AND A CLIENT CANNOT FIND OUT WHICH IT HAS.
25
- * There is deliberately no `includesDrafts()` here, because it cannot be
26
- * implemented: `apiKeyEnvironment` is set in verifyApiKey.ts:57, consumed
27
- * internally to compute `includeDrafted`, and returned to the caller by NO
28
- * endpoint. /v2/schema carries only {models, relations, checksum, generatedAt}.
29
- *
30
- * So a site handed a `draft`, `staging` or `development` key serves unpublished
31
- * content to the public, and has no way to detect it — not at startup, not at
32
- * runtime, not from any response. In production-shaped data 29 of 74 keys are
33
- * non-production. Whatever this SDK offers, it cannot make that safe; the fix
34
- * is for Capa to report the key's environment on a read a client already makes.
35
- */
36
1
  export interface CapaConfig {
37
2
  /** Base URL of the Capa API, e.g. https://api.example.com. No trailing slash required. */
38
3
  baseUrl: string;
package/dist/config.js CHANGED
@@ -1,13 +1,59 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveConfig = resolveConfig;
4
+ /**
5
+ * Client configuration, and the one thing about it that is not obvious.
6
+ *
7
+ * PREVIEW IS A KEY, NOT A FLAG
8
+ * Capa gates unpublished content on the API key's `environment` column, not on
9
+ * anything in the request:
10
+ *
11
+ * // apps/api/src/routes/v2/api.ts:203
12
+ * const environment = req.apiKeyEnvironment || "production";
13
+ * const includeDrafted = environment === "production" ? false : true;
14
+ *
15
+ * So there is no `?preview=true` to pass, and `getContent(ns, id, {preview})`
16
+ * CANNOT be implemented against a published key — the server would ignore it.
17
+ * The honest surface is one client per key:
18
+ *
19
+ * const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY })
20
+ * const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY })
21
+ *
22
+ * Note also that the comparison above is exact and case-sensitive against a
23
+ * free-text column, so ANY environment that is not literally "production"
24
+ * returns drafts — `staging`, `development`, `draft`, and equally a typo like
25
+ * "Production".
26
+ *
27
+ * AND A CLIENT CANNOT FIND OUT WHICH IT HAS.
28
+ * There is deliberately no `includesDrafts()` here, because it cannot be
29
+ * implemented: `apiKeyEnvironment` is set in verifyApiKey.ts:57, consumed
30
+ * internally to compute `includeDrafted`, and returned to the caller by NO
31
+ * endpoint. /v2/schema carries only {models, relations, checksum, generatedAt}.
32
+ *
33
+ * So a site handed a `draft`, `staging` or `development` key serves unpublished
34
+ * content to the public, and has no way to detect it — not at startup, not at
35
+ * runtime, not from any response. In production-shaped data 29 of 74 keys are
36
+ * non-production. Whatever this SDK offers, it cannot make that safe; the fix
37
+ * is for Capa to report the key's environment on a read a client already makes.
38
+ */
39
+ /**
40
+ * The platform fetch, called through `globalThis` on every request. Storing
41
+ * `globalThis.fetch` and calling it as a method of the config object gives it
42
+ * the wrong `this`, and a browser refuses that ("Illegal invocation"). Looking
43
+ * it up per call also picks up a fetch a framework patches in later.
44
+ */
45
+ function defaultFetch() {
46
+ if (typeof globalThis.fetch !== "function")
47
+ return undefined;
48
+ return ((input, init) => globalThis.fetch(input, init));
49
+ }
4
50
  function resolveConfig(config) {
5
51
  const missing = ["baseUrl", "apiKey", "tenantId"].filter((k) => !config[k]);
6
52
  if (missing.length) {
7
53
  throw new Error(`@capacms/sdk: missing ${missing.join(", ")}. ` +
8
54
  `createClient needs baseUrl, apiKey and tenantId.`);
9
55
  }
10
- const fetchImpl = config.fetch ?? globalThis.fetch;
56
+ const fetchImpl = config.fetch ?? defaultFetch();
11
57
  if (typeof fetchImpl !== "function") {
12
58
  throw new Error("@capacms/sdk: no fetch available. Pass one via config.fetch on older runtimes.");
13
59
  }
@@ -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 {
@@ -387,8 +387,9 @@ export type BuilderCallOptions<O extends GraphQLCallOptions = GraphQLCallOptions
387
387
  /**
388
388
  * `client.graphql` over one way of reading a document: called with a
389
389
  * document, and `query()` printing a selection's document for it. `createClient`
390
- * reads straight from the API, and `getCapaClient` from `/nextjs` through
391
- * Next's data cache, with the same refusals.
390
+ * reads straight from the API, and `getCapaClient` from `/nextjs` the same way
391
+ * unless a call gives `tags` or `revalidate`, then through Next's data cache,
392
+ * with the same refusals.
392
393
  */
393
394
  export declare function graphqlClient<Q, O extends GraphQLCallOptions>(read: (document: string, variables: Record<string, unknown> | undefined, options: O | undefined) => Promise<GraphQLResult<unknown>>): GraphQLClient<Q, O>;
394
395
  export interface CapaNextClient<Q = UntypedQuery, O extends GraphQLCallOptions = GraphQLCallOptions> {
@@ -400,8 +401,8 @@ export interface CapaNextClient<Q = UntypedQuery, O extends GraphQLCallOptions =
400
401
  * Returns the claim, or NULL when the token is invalid or expired, because
401
402
  * both mean the same thing to a preview route: do not enable draft mode.
402
403
  * Every other failure throws, so a Capa outage does not look like a bad link.
403
- * Needs a `cap_` key: a client built with a legacy key throws a
404
- * `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.
405
406
  */
406
407
  preview(token: string, options?: CallOptions): Promise<PreviewClaim | null>;
407
408
  me<T = Record<string, unknown>>(options?: CallOptions): Promise<T>;
@@ -18,6 +18,19 @@ const typed_1 = require("./graphql/typed");
18
18
  var errors_2 = require("./errors");
19
19
  Object.defineProperty(exports, "CapaError", { enumerable: true, get: function () { return errors_2.CapaError; } });
20
20
  Object.defineProperty(exports, "isCapaError", { enumerable: true, get: function () { return errors_2.isCapaError; } });
21
+ /**
22
+ * The platform fetch, called through `globalThis` on every request. Storing
23
+ * `globalThis.fetch` and calling it as a method of the config object gives it
24
+ * the wrong `this`, and a browser refuses that ("Failed to execute 'fetch' on
25
+ * 'Window': Illegal invocation"); Node does not care, which is why only
26
+ * browser reads broke. Looking it up per call also picks up a fetch a
27
+ * framework patches in after the client is built (Next does).
28
+ */
29
+ function defaultFetch() {
30
+ if (typeof globalThis.fetch !== "function")
31
+ return undefined;
32
+ return ((input, init) => globalThis.fetch(input, init));
33
+ }
21
34
  /**
22
35
  * What a `Capa-Schema` value may look like: hex, 8 to 64 characters.
23
36
  *
@@ -35,8 +48,9 @@ const BUILDER_NOT_PERSISTED = "@capacms/sdk/next: graphql.query() cannot send pe
35
48
  /**
36
49
  * `client.graphql` over one way of reading a document: called with a
37
50
  * document, and `query()` printing a selection's document for it. `createClient`
38
- * reads straight from the API, and `getCapaClient` from `/nextjs` through
39
- * Next's data cache, with the same refusals.
51
+ * reads straight from the API, and `getCapaClient` from `/nextjs` the same way
52
+ * unless a call gives `tags` or `revalidate`, then through Next's data cache,
53
+ * with the same refusals.
40
54
  */
41
55
  function graphqlClient(read) {
42
56
  const query = async (selection, options) => {
@@ -61,7 +75,7 @@ function resolveNextConfig(config) {
61
75
  if (value.contract !== undefined && value.contract !== 1) {
62
76
  throw new Error("@capacms/sdk/next: contract must be 1 when provided.");
63
77
  }
64
- const fetchImpl = value.fetch ?? globalThis.fetch;
78
+ const fetchImpl = value.fetch ?? defaultFetch();
65
79
  if (typeof fetchImpl !== "function") {
66
80
  throw new Error("@capacms/sdk/next: no fetch available. Pass one via config.fetch.");
67
81
  }
@@ -487,7 +501,8 @@ function createClient(config) {
487
501
  return readSchema(options.signal);
488
502
  },
489
503
  async preview(token, options = {}) {
490
- (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).
491
506
  const query = new URLSearchParams();
492
507
  query.set("token", token);
493
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) {