@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.
@@ -2,16 +2,18 @@
2
2
  * key-family.ts — which keys `@capacms/sdk/next` takes, and for what.
3
3
  *
4
4
  * A `cap_` key is the `/api/` key: scoped, hashed at rest, and the only kind
5
- * a draft preview takes, both to verify a preview token and to read drafts.
6
- * Every other key is legacy, which is the API's rule
7
- * (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and `sk_` keys, and
8
- * the unprefixed keys older tenants were minted, all reach `/api/` with the
9
- * grants their permission gives. So every read call takes them, and the first
10
- * client built with one warns once per process, naming the key to mint.
5
+ * the draft clients take to read drafts. Every other key is legacy, which is
6
+ * the API's rule (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and
7
+ * `sk_` keys, and the unprefixed keys older tenants were minted, all reach
8
+ * `/api/` with the grants their permission gives. So every read call takes
9
+ * them, `preview()` included: `GET /api/preview` asks for `instance:read`,
10
+ * which every legacy permission grants, and refuses a token minted for another
11
+ * tenant. The first client built with one warns once per process, naming the
12
+ * key to mint.
11
13
  *
12
14
  * The `cap_` prefix is the whole rule, case-sensitive, as on the API:
13
15
  * `CAP_live_…` is not a key Capa mints, so it is looked up as a legacy key.
14
- * `@capa/mcp` applies the same rule, and both packages are tested against one
16
+ * `@capacms/mcp` applies the same rule, and both packages are tested against one
15
17
  * vector file, `test/fixtures/key-family.json`.
16
18
  */
17
19
  export type KeyFamily = "cap" | "legacy";
@@ -19,11 +21,6 @@ export type KeyFamily = "cap" | "legacy";
19
21
  export declare function keyFamily(apiKey: unknown): KeyFamily | null;
20
22
  /** Warn about a legacy key once per process: a site builds a client per request. */
21
23
  export declare function warnLegacyKeyOnce(apiKey: string): void;
22
- /**
23
- * Throws for a legacy key: verifying a preview token takes a `cap_` key. A
24
- * value that is no key at all is `resolveNextConfig`'s to refuse, by name.
25
- */
26
- export declare function requirePreviewKey(apiKey: string): void;
27
24
  /**
28
25
  * Throws for a legacy key: a draft read takes a `cap_` key. `holder` names
29
26
  * where the key came from (`CAPA_DRAFT_KEY`, `the draft config`), so the
@@ -3,22 +3,23 @@
3
3
  * key-family.ts — which keys `@capacms/sdk/next` takes, and for what.
4
4
  *
5
5
  * A `cap_` key is the `/api/` key: scoped, hashed at rest, and the only kind
6
- * a draft preview takes, both to verify a preview token and to read drafts.
7
- * Every other key is legacy, which is the API's rule
8
- * (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and `sk_` keys, and
9
- * the unprefixed keys older tenants were minted, all reach `/api/` with the
10
- * grants their permission gives. So every read call takes them, and the first
11
- * client built with one warns once per process, naming the key to mint.
6
+ * the draft clients take to read drafts. Every other key is legacy, which is
7
+ * the API's rule (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and
8
+ * `sk_` keys, and the unprefixed keys older tenants were minted, all reach
9
+ * `/api/` with the grants their permission gives. So every read call takes
10
+ * them, `preview()` included: `GET /api/preview` asks for `instance:read`,
11
+ * which every legacy permission grants, and refuses a token minted for another
12
+ * tenant. The first client built with one warns once per process, naming the
13
+ * key to mint.
12
14
  *
13
15
  * The `cap_` prefix is the whole rule, case-sensitive, as on the API:
14
16
  * `CAP_live_…` is not a key Capa mints, so it is looked up as a legacy key.
15
- * `@capa/mcp` applies the same rule, and both packages are tested against one
17
+ * `@capacms/mcp` applies the same rule, and both packages are tested against one
16
18
  * vector file, `test/fixtures/key-family.json`.
17
19
  */
18
20
  Object.defineProperty(exports, "__esModule", { value: true });
19
21
  exports.keyFamily = keyFamily;
20
22
  exports.warnLegacyKeyOnce = warnLegacyKeyOnce;
21
- exports.requirePreviewKey = requirePreviewKey;
22
23
  exports.requireDraftKey = requireDraftKey;
23
24
  exports.__resetKeyWarningForTests = __resetKeyWarningForTests;
24
25
  const LEGACY_PREFIX = /^(pk|sk)_/;
@@ -46,17 +47,8 @@ function warnLegacyKeyOnce(apiKey) {
46
47
  if (warned)
47
48
  return;
48
49
  warned = true;
49
- console.warn(`@capacms/sdk/next: this client reads with a ${legacyKind(apiKey)}. Reads work as they do with a cap_ key. ` +
50
- `Draft previews need a cap_ key: mint one ${MINT}`);
51
- }
52
- /**
53
- * Throws for a legacy key: verifying a preview token takes a `cap_` key. A
54
- * value that is no key at all is `resolveNextConfig`'s to refuse, by name.
55
- */
56
- function requirePreviewKey(apiKey) {
57
- if (!isLegacy(apiKey))
58
- return;
59
- throw new TypeError(`@capacms/sdk/next: preview needs a cap_ key, and this client holds a ${legacyKind(apiKey)}. Mint a cap_ key ${MINT}`);
50
+ console.warn(`@capacms/sdk/next: this client reads with a ${legacyKind(apiKey)}. Reads and preview links work as they do with a cap_ key. ` +
51
+ `Draft reads need a cap_ key: mint one ${MINT}`);
60
52
  }
61
53
  /**
62
54
  * Throws for a legacy key: a draft read takes a `cap_` key. `holder` names
@@ -13,11 +13,12 @@ export declare function withCache(fetchImpl: typeof fetch, options: CacheOptions
13
13
  */
14
14
  export declare function modelTag(namespace: string): string;
15
15
  /**
16
- * The tag `graphql()` gives a read that names no `tags`, and that
17
- * `revalidateFromWebhook` revalidates on every content change: such a page is
18
- * never stale after a publish, at the price of refreshing on any publish.
19
- * Name the models it reads with `tagsFor({ namespace })` to refresh it only
20
- * when one of those changes.
16
+ * The tag `graphql()` keeps a read under when it gives a `revalidate` and no
17
+ * `tags`, and that `revalidateFromWebhook` revalidates on every content
18
+ * change: such a page is never stale after a publish, at the price of
19
+ * refreshing on any publish. Name the models it reads with
20
+ * `tagsFor({ namespace })` to refresh it only when one of those changes. A
21
+ * read with neither `tags` nor `revalidate` is not kept at all.
21
22
  */
22
23
  export declare const GRAPHQL_TAG = "capa:graphql";
23
24
  /**
@@ -110,6 +111,10 @@ export type { PreviewClaim };
110
111
  * clickable. It never switches the site to draft data.
111
112
  */
112
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";
113
118
  /**
114
119
  * The request header `resolveEditRequest` sets once a `capa-edit` token has
115
120
  * been verified, for `editMode()` to read. Any copy a browser sent is removed
@@ -156,6 +161,8 @@ export interface EditRequest {
156
161
  headers: Headers;
157
162
  /** `EDIT_CACHE_CONTROL` in edit mode, otherwise null. Set it on the response. */
158
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;
159
166
  }
160
167
  /**
161
168
  * The middleware half of edit mode.
@@ -164,6 +171,7 @@ export interface EditRequest {
164
171
  * const edit = await resolveEditRequest(request, publishedClient());
165
172
  * const response = NextResponse.next({ request: { headers: edit.headers } });
166
173
  * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
174
+ * if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
167
175
  * return response;
168
176
  * }
169
177
  *
@@ -219,10 +227,12 @@ export declare function getPublishedClient<Q = UntypedQuery>(overrides?: Partial
219
227
  * `CapaQuery` (from `capa-codegen --graphql`) types the builder, as with
220
228
  * `createClient`; leave it out for an untyped client. `config` wins over env.
221
229
  *
222
- * Its GraphQL reads, a document or the builder, are kept in Next's data cache
223
- * exactly as `graphql()` keeps them, each under the `tags` and `revalidate`
224
- * it is called with: a published read with no errors, never a draft or an
225
- * edit-mode page. Its REST reads are sent as `createClient` sends them.
230
+ * Its REST reads are sent as `createClient` sends them, which Next does not
231
+ * keep. Its GraphQL reads, a document or the builder, are sent the same way
232
+ * unless a call gives `tags` or `revalidate`, so a publish shows up on both
233
+ * alike. Given either, a read is kept in Next's data cache exactly as
234
+ * `graphql()` keeps it: a published read with no errors, never a draft or an
235
+ * edit-mode page.
226
236
  */
227
237
  export declare function getCapaClient<Q = UntypedQuery>(input: {
228
238
  draftMode: DraftModeFn;
@@ -234,14 +244,16 @@ export declare function getCapaClient<Q = UntypedQuery>(input: {
234
244
  /** Where a GraphQL read in Next keeps its answer: Next's data cache, as `graphql()` and `getCapaClient` keep it. */
235
245
  export interface NextCacheOptions extends GraphQLCallOptions {
236
246
  /**
237
- * Next.js cache tags for this read. `tagsFor({ namespace: <Name>Models })`,
238
- * with the list `capa-codegen --graphql` writes beside each document, tags
239
- * it with every model the query reads, which `revalidateFromWebhook`
240
- * revalidates on a publish. Without tags the read is tagged `GRAPHQL_TAG`,
241
- * which every publish revalidates.
247
+ * Next.js cache tags to keep this read under. `tagsFor({ namespace:
248
+ * <Name>Models })`, with the list `capa-codegen --graphql` writes beside
249
+ * each document, tags it with every model the query reads, which
250
+ * `revalidateFromWebhook` revalidates on a publish. Given a `revalidate`
251
+ * and no tags, the read is kept under `GRAPHQL_TAG`, which every publish
252
+ * revalidates. Given neither, the read is not kept: it is sent as a REST
253
+ * read is, on every render.
242
254
  */
243
255
  tags?: string[];
244
- /** Seconds to cache, or `false` to cache until a tag is revalidated. */
256
+ /** Seconds to cache, `false` to cache until a tag is revalidated, or 0 not to cache. */
245
257
  revalidate?: number | false;
246
258
  }
247
259
  export interface NextGraphQLOptions extends NextCacheOptions {
@@ -261,9 +273,10 @@ export interface NextGraphQLOptions extends NextCacheOptions {
261
273
  config?: Partial<CapaNextConfig>;
262
274
  /**
263
275
  * Next's `unstable_cache`, from `next/cache`. Leave it out: `graphql()`
264
- * loads Next's own. A published read is kept in Next's data cache only when
265
- * it answered with no `errors`, under `tags` for `revalidate` seconds, or
266
- * with no `revalidate` until one of its tags is revalidated. Next's fetch
276
+ * loads Next's own. A published read given `tags` or a `revalidate` is kept
277
+ * in Next's data cache only when it answered with no `errors`, under `tags`
278
+ * for `revalidate` seconds, or with no `revalidate` until one of its tags is
279
+ * revalidated. Next's fetch
267
280
  * cache is not used for it: that cache keeps any 200, and a GraphQL error
268
281
  * is a 200 (a root field that timed out), and in Next 15 it keeps nothing
269
282
  * without a `revalidate`, tags or not. Pass it only to supply another.
@@ -293,12 +306,15 @@ export type UnstableCache = <T extends (...args: any[]) => Promise<any>>(cb: T,
293
306
  * variables are typed from it, as they are for a `<Name>Document` it writes,
294
307
  * and `LatestModels` lists the models it reads.
295
308
  *
296
- * A published read is kept in Next's data cache (`unstable_cache`) when it
297
- * answered with no errors: under `tags`, for `revalidate` seconds, or with no
298
- * `revalidate` until `revalidateFromWebhook` revalidates one of its tags on a
299
- * publish. It goes as a GET, which the CDN and the API also cache for the
300
- * published key; a document too long for a GET URL goes as a POST, which only
301
- * Next's data cache keeps. Under draft mode the read uses `CAPA_DRAFT_KEY`, a
309
+ * With no `tags` and no `revalidate` a read is sent as the REST reads are,
310
+ * and Next keeps nothing: the page shows a publish on its next render, as a
311
+ * page reading by REST does. Given either, a published read is kept in Next's
312
+ * data cache (`unstable_cache`) when it answered with no errors: under `tags`,
313
+ * for `revalidate` seconds, or with no `revalidate` until
314
+ * `revalidateFromWebhook` revalidates one of its tags on a publish. It goes
315
+ * as a GET, which the CDN and the API also cache for the published key; a
316
+ * document too long for a GET URL goes as a POST, which only Next's data
317
+ * cache keeps. Under draft mode the read uses `CAPA_DRAFT_KEY`, a
302
318
  * `cap_` key, and bypasses the cache (the API answers a development key's GET
303
319
  * `no-store`), the same split `getCapaClient` makes for REST, and in edit mode
304
320
  * each entry that selects `id` and `model` is marked for `capaAttrs`, uncached.
@@ -314,29 +330,149 @@ export declare function graphql<D extends keyof CapaDocuments>(document: D, ...r
314
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>>;
315
331
  /** Only a path on this site: never `//elsewhere.example` or a full URL. */
316
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>;
317
428
  /**
318
429
  * `app/api/capa/preview/route.ts`:
319
430
  *
320
- * import { draftMode } from "next/headers";
321
- * import { redirect } from "next/navigation";
322
- * export const GET = createPreviewRoute({ draftMode, redirect });
431
+ * import { cookies, draftMode } from "next/headers";
432
+ * export const GET = createPreviewRoute({ draftMode, cookies });
323
433
  *
324
434
  * Checks the token with Capa (the site never holds the signing key), turns
325
435
  * draft mode on and lands on the entry's page. A bad or expired token lands on
326
436
  * the page without draft mode and `?preview=expired`; Capa unreachable gives
327
- * `?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.
328
447
  */
329
448
  export declare function createPreviewRoute(input: {
330
449
  draftMode: DraftModeFn;
331
- redirect: (url: string) => never | void;
450
+ /** Next's `cookies`. Given, the draft cookie lasts `maxAge` seconds and works in the editor's frame. */
451
+ cookies?: CookiesFn;
452
+ /** Next's `redirect`. Leave it out for a redirect that carries the draft headers. */
453
+ redirect?: (url: string) => never | void;
332
454
  client?: () => Pick<CapaNextClient, "preview">;
333
- /** 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). */
334
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;
335
461
  }): (request: Request) => Promise<Response | void>;
336
- /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
462
+ /**
463
+ * `app/api/capa/exit/route.ts`:
464
+ *
465
+ * import { cookies, draftMode } from "next/headers";
466
+ * export const GET = exitPreviewRoute({ draftMode, cookies });
467
+ *
468
+ * Turns draft mode off and lands on `?path=`. Given `cookies`, the framed
469
+ * cookie `createPreviewRoute` set is deleted too. With no `redirect`, the route
470
+ * answers with its own 307, marked noindex and never cached.
471
+ */
337
472
  export declare function exitPreviewRoute(input: {
338
473
  draftMode: DraftModeFn;
339
- redirect: (url: string) => never | void;
474
+ cookies?: CookiesFn;
475
+ redirect?: (url: string) => never | void;
340
476
  }): (request: Request) => Promise<Response | void>;
341
477
  interface MiddlewareRequest {
342
478
  url: string;
@@ -372,7 +508,20 @@ interface NextResponseLike {
372
508
  * - `?capa-view=published` renders without the draft cookie, for the editor's
373
509
  * Published view;
374
510
  * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
375
- * - 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
+ * };
376
525
  */
377
526
  export declare function capaMiddleware(input: {
378
527
  NextResponse: NextResponseLike;