@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 +33 -1
- package/README.md +148 -26
- package/dist/next/client.d.ts +6 -6
- package/dist/next/client.js +2 -1
- package/dist/next/field-names.js +1 -1
- package/dist/next/graphql/introspection.d.ts +1 -1
- package/dist/next/graphql/introspection.js +1 -1
- package/dist/next/graphql/plan.js +2 -2
- package/dist/next/key-family.d.ts +9 -12
- package/dist/next/key-family.js +11 -19
- package/dist/nextjs/index.d.ts +149 -9
- package/dist/nextjs/index.js +207 -15
- package/package.json +2 -2
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 `@
|
|
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 `
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
`
|
|
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_
|
|
1277
|
-
|
|
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,
|
|
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
|
-
//
|
|
1350
|
-
|
|
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
|
-
|
|
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
|
|
1445
|
-
browser
|
|
1446
|
-
`SameSite=None; Secure
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
`
|
|
1455
|
-
|
|
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
|
|
package/dist/next/client.d.ts
CHANGED
|
@@ -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;
|
|
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 `@
|
|
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
|
-
*
|
|
405
|
-
*
|
|
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>;
|
package/dist/next/client.js
CHANGED
|
@@ -501,7 +501,8 @@ function createClient(config) {
|
|
|
501
501
|
return readSchema(options.signal);
|
|
502
502
|
},
|
|
503
503
|
async preview(token, options = {}) {
|
|
504
|
-
|
|
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 {
|
package/dist/next/field-names.js
CHANGED
|
@@ -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
|
-
* `@
|
|
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: `@
|
|
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: `@
|
|
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. `@
|
|
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. @
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
* `@
|
|
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
|
package/dist/next/key-family.js
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
* `@
|
|
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
|
|
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
|
package/dist/nextjs/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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;
|
package/dist/nextjs/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
768
|
+
return go(safeSitePath(claim.path ?? path));
|
|
609
769
|
};
|
|
610
770
|
}
|
|
611
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
}
|