@elytracms/next 0.0.93 → 0.0.94

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/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ import { Clock } from '@elytracms/core/determinism';
13
13
  import { ResolvingMetadata, Metadata, ResolvedMetadata, MetadataRoute } from 'next';
14
14
  import { RevalidationEvent } from '@elytracms/core/revalidation';
15
15
  export { REVALIDATE_SIGNATURE_HEADER, RevalidationEvent, RevalidationEventKind, revalidationEventSchema, signRevalidationBody, signedRevalidationRequest } from '@elytracms/core/revalidation';
16
+ import { IngestPayload } from '@elytracms/core/ingest';
16
17
  import { ImageLoader } from 'next/image';
17
18
  import { HostImageRenderer } from './host-image.js';
18
19
  export { z } from 'zod';
@@ -378,6 +379,25 @@ interface CanvasWrapContext extends CanvasRequestInfo {
378
379
  */
379
380
  result: ResolvePageResult;
380
381
  }
382
+ /**
383
+ * EC-618 — what the route's `analytics` slot is told about the page it renders on: the
384
+ * resolved document (the content model, not the URL — slug changes and locale paths then
385
+ * attribute correctly), the locale it rendered in, the request path and the status. Plain
386
+ * serializable values, so the slot may be a client component.
387
+ */
388
+ interface CanvasBeaconProps {
389
+ /** The request path as served (including any locale prefix). */
390
+ path: string;
391
+ /** 200: the route renders the slot only on a rendered page (a 404 never reaches it). */
392
+ status: 200;
393
+ /** The locale the page rendered in. */
394
+ locale: string;
395
+ /** The resolved document's collection and id — absent when the URL resolved to no document. */
396
+ collection?: string;
397
+ documentId?: string;
398
+ }
399
+ /** PURE core (EC-618): the slot's props from a render outcome's resolution and the request info. */
400
+ declare function canvasBeaconProps(result: ResolvePageResult, info: CanvasRequestInfo): CanvasBeaconProps;
381
401
  type CanvasPageOutcome = {
382
402
  kind: 'redirect';
383
403
  target: string;
@@ -729,6 +749,21 @@ interface CanvasRouteOptions {
729
749
  * the default.
730
750
  */
731
751
  wrap?: (children: ReactNode, ctx: CanvasWrapContext) => ReactNode;
752
+ /**
753
+ * The ANALYTICS slot (EC-618): a component the route renders inside every `render`
754
+ * outcome, AFTER the page (and outside `wrap`, so a wrapper cannot drop it), with the
755
+ * resolved document, the locale, the path and the status ({@link CanvasBeaconProps}).
756
+ * Published perspective only — a draft preview is an editor at work, not a visit.
757
+ *
758
+ * The engine supplies the slot and nothing else: its value is the beacon of the
759
+ * analytics addon, `@elytracms/analytics/next` — **unpublished, pending counsel's
760
+ * verdict on its consent-free claim** (EC-618) — or a host's own component. Absent, the
761
+ * route renders exactly what it rendered before, and sends nothing.
762
+ *
763
+ * A not-found page never reaches this slot (`notFound()` renders the host's
764
+ * `not-found.tsx`), so a 404 beacon lives in that file.
765
+ */
766
+ analytics?: ComponentType<CanvasBeaconProps>;
732
767
  }
733
768
  interface CanvasRouteProps {
734
769
  params: Promise<{
@@ -1058,6 +1093,177 @@ interface RevalidateRouteHandlers {
1058
1093
  */
1059
1094
  declare function createRevalidateRoute(options: RevalidateRouteOptions): RevalidateRouteHandlers;
1060
1095
 
1096
+ /**
1097
+ * EC-617 — the pure decision core of the PUBLIC INGEST route (`createIngestRoute`, the
1098
+ * Next wrapper in `ingest.ts`): no Next imports, no network, an injected clock — the
1099
+ * house "pure core + thin wrapper" shape of `evaluateRevalidateRequest` /
1100
+ * `createRevalidateRoute`.
1101
+ *
1102
+ * The route is a site's one public write path (ruling EC-565 § 6c). It runs, in order:
1103
+ *
1104
+ * 1. **method** — `POST` only (405);
1105
+ * 2. **configured** — the server-held `INGEST_SECRET` and the receiver URL are set (503
1106
+ * otherwise: the route stays closed until deliberately configured, never a no-op);
1107
+ * 3. **same-origin** — a browser request from another origin (`Origin` / `Sec-Fetch-Site`)
1108
+ * is refused (403). The route emits no CORS headers and reads no cookie;
1109
+ * 4. **rate limit** — a fixed window per CLIENT KEY (429, `Retry-After`);
1110
+ * 5. **size** — the raw body capped at `maxBodyBytes` (413), checked before parsing;
1111
+ * 6. **shape** — JSON in the ingest envelope (400, with the issues);
1112
+ * 7. **bot gate** — the host's own check, an injected seam (403, `{ gate: 'bot' }`): the
1113
+ * engine supplies the slot, never the vendor;
1114
+ * 8. **enrichment** (EC-618) — an optional seam an ADDON fills (the analytics addon derives
1115
+ * a country and a visitor's sketch coordinates here, where the request is): it returns the
1116
+ * payload to forward, which is re-validated against the envelope and the receiver's cap
1117
+ * (500 — the site's own fault — when it throws or returns something invalid). Absent, the
1118
+ * route is exactly what it was;
1119
+ *
1120
+ * then the wrapper signs the payload with the server-held secret and forwards it to the
1121
+ * one Convex receiver, relaying its answer. Only the PAYLOAD is forwarded — never a request
1122
+ * header — so whatever an enrichment reads from the request (the client address, the user
1123
+ * agent, a geo header) stays in this request's memory; only what it derives travels.
1124
+ */
1125
+ /** The answer the core gives the wrapper. */
1126
+ type IngestEvaluation = {
1127
+ ok: true;
1128
+ payload: IngestPayload;
1129
+ } | {
1130
+ ok: false;
1131
+ status: 400 | 403 | 405 | 413 | 429 | 500 | 503;
1132
+ body: Record<string, unknown>;
1133
+ headers?: Record<string, string>;
1134
+ };
1135
+ /** What the host's bot gate answers (a Turnstile / hCaptcha / honeypot check the HOST implements). */
1136
+ type BotGateVerdict = {
1137
+ allow: true;
1138
+ } | {
1139
+ allow: false;
1140
+ reason: string;
1141
+ };
1142
+ /**
1143
+ * The bot-gate seam: called with the request and the parsed payload, AFTER the cheap
1144
+ * checks (so a flood never reaches a paid verification API). Absent → no gate.
1145
+ */
1146
+ type IngestBotGate = (input: {
1147
+ request: Request;
1148
+ payload: IngestPayload;
1149
+ }) => BotGateVerdict | Promise<BotGateVerdict>;
1150
+ /**
1151
+ * EC-618 — the enrichment seam: called with the request and the parsed payload LAST (after
1152
+ * the bot gate), it returns the payload the route signs and forwards. The engine supplies
1153
+ * the slot, never the code: an addon fills it (`@elytracms/analytics/next`'s
1154
+ * `analyticsEnrichment`), and a host may compose its own. Whatever it reads from the
1155
+ * request is held for this request only — the route forwards the returned payload and
1156
+ * nothing else. Absent → no enrichment, the payload forwarded as parsed.
1157
+ */
1158
+ type IngestEnrich = (input: {
1159
+ request: Request;
1160
+ payload: IngestPayload;
1161
+ }) => IngestPayload | Promise<IngestPayload>;
1162
+ /**
1163
+ * The fixed-window limiter — the AI route's pattern (EC-602), keyed per client. Holds,
1164
+ * in memory only, one `{ windowStart, count }` per key seen in the current window; expired
1165
+ * windows are dropped as the clock moves, so the map is bounded by the clients of one
1166
+ * window. Each server instance holds its own windows (a serverless platform runs several),
1167
+ * so the limit is per instance — the same documented limitation as the AI route.
1168
+ */
1169
+ interface FixedWindowLimiter {
1170
+ /** Count one request for `key`; `retryAfterSeconds` when it is over the limit. */
1171
+ hit(key: string): {
1172
+ allowed: true;
1173
+ } | {
1174
+ allowed: false;
1175
+ retryAfterSeconds: number;
1176
+ };
1177
+ /** How many keys the limiter currently holds (for the bound test). */
1178
+ size(): number;
1179
+ }
1180
+ declare function createFixedWindowLimiter(options: {
1181
+ limit: number;
1182
+ windowMs?: number;
1183
+ now?: () => number;
1184
+ }): FixedWindowLimiter;
1185
+ /**
1186
+ * The DEFAULT client key: the first hop of `x-forwarded-for` (what Vercel and most proxies
1187
+ * set), else `x-real-ip`, else one shared `unknown` bucket. An IP address is personal
1188
+ * data: it is held ONLY as a key of the in-memory window above, for at most one window,
1189
+ * and is never stored, logged or forwarded to the receiver. A host that wants a different
1190
+ * key (a hashed one, a session id) passes `clientKey`.
1191
+ */
1192
+ declare function defaultIngestClientKey(request: Request): string;
1193
+ /** Is this a cross-origin BROWSER request? (A request with no browser headers is not judged here.) */
1194
+ declare function isCrossOrigin(request: Request): boolean;
1195
+ interface IngestEvaluationInput {
1196
+ request: Request;
1197
+ /** The raw body, read by the wrapper up to `maxBodyBytes + 1` bytes (`undefined` when over). */
1198
+ rawBody: string | undefined;
1199
+ configured: boolean;
1200
+ limiter: FixedWindowLimiter;
1201
+ clientKey: (request: Request) => string;
1202
+ botGate?: IngestBotGate;
1203
+ enrich?: IngestEnrich;
1204
+ }
1205
+ /** Evaluate one request (see the module header for the order). */
1206
+ declare function evaluateIngestRequest(input: IngestEvaluationInput): Promise<IngestEvaluation>;
1207
+ /**
1208
+ * Read a request body as text, at most `maxBytes` (counted in UTF-8 bytes). `undefined`
1209
+ * when it is longer — the over-size body is never buffered whole. A declared
1210
+ * `Content-Length` over the cap is refused before a byte is read.
1211
+ */
1212
+ declare function readCappedBody(request: Request, maxBytes?: number): Promise<string | undefined>;
1213
+
1214
+ /**
1215
+ * EC-617 — the PUBLIC INGEST route: a site's one write path into its project (form
1216
+ * submissions, analytics events, upload URLs), mounted SAME-ORIGIN in the site's own
1217
+ * App Router:
1218
+ *
1219
+ * ```ts
1220
+ * // frontend/app/api/ingest/route.ts — through the delivery kit
1221
+ * export const { POST } = elytra.ingestRoute()
1222
+ * ```
1223
+ *
1224
+ * The decisions are the pure core's (`evaluateIngestRequest`: method, configured,
1225
+ * same-origin, rate limit, size, shape, the host's bot gate); this wrapper then SIGNS the
1226
+ * payload with the server-held `INGEST_SECRET` (it never reaches a browser) and forwards it
1227
+ * to the deployment's ONE receiver (`<deployment>.convex.site/elytra/ingest`), relaying the
1228
+ * receiver's answer. A receiver that rejects the signature is a SITE misconfiguration
1229
+ * (the two secrets differ), answered 502 with that sentence — never a 401 a visitor could
1230
+ * read as their fault.
1231
+ *
1232
+ * Uploads leave same-origin by design: an `upload` payload asks the receiver for ONE
1233
+ * short-lived storage URL (the request rides this route's gate, limiter and bot gate), and
1234
+ * the browser then sends the bytes straight to that URL; the submission that follows claims
1235
+ * the stored file, which is checked against what the upload request declared.
1236
+ */
1237
+ interface IngestRouteOptions {
1238
+ /** The server-held HMAC secret (= the deployment's `ELYTRA_INGEST_SECRET`). Unset → 503. */
1239
+ secret: string | undefined;
1240
+ /** The deployment's HTTP origin (`https://<name>.convex.site`); the receiver path is appended. Unset → 503. */
1241
+ siteUrl: string | undefined;
1242
+ /** Cap on the raw JSON body, ≤ the receiver's `INGEST_MAX_BODY_BYTES` (default). */
1243
+ maxBodyBytes?: number;
1244
+ /** Requests per client key per minute (default 30). */
1245
+ maxRequestsPerMinute?: number;
1246
+ /** The rate limiter's key for a request (default: the client IP, in memory only — see `defaultIngestClientKey`). */
1247
+ clientKey?: (request: Request) => string;
1248
+ /** The host's bot check (Turnstile, hCaptcha, a honeypot …). The engine supplies the slot, never the vendor. */
1249
+ botGate?: IngestBotGate;
1250
+ /**
1251
+ * EC-618 — an addon's enrichment, run last: it returns the payload to sign and forward
1252
+ * (the analytics addon adds a country and the visitor's sketch coordinates, derived from
1253
+ * the request here and never forwarded raw). Absent → the payload is forwarded as parsed.
1254
+ */
1255
+ enrich?: IngestEnrich;
1256
+ /** Injected clock (tests). */
1257
+ now?: () => number;
1258
+ /** Injected transport (tests). */
1259
+ fetchImpl?: typeof fetch;
1260
+ }
1261
+ interface IngestRouteHandlers {
1262
+ POST(request: Request): Promise<Response>;
1263
+ }
1264
+ /** Create the ingest route handler (see the module header). */
1265
+ declare function createIngestRoute(options: IngestRouteOptions): IngestRouteHandlers;
1266
+
1061
1267
  /**
1062
1268
  * Image delivery for the embedded runtime (EC-152).
1063
1269
  *
@@ -1253,6 +1459,8 @@ declare function elytraHostImage(options?: ElytraHostImageOptions): HostImageRen
1253
1459
  * | | equal the Convex deployment's own env var) |
1254
1460
  * | `REVALIDATE_SECRET` | Instant-publish webhook HMAC secret (must equal the |
1255
1461
  * | | deployment's `CANVAS_REVALIDATE_SECRET`) |
1462
+ * | `INGEST_SECRET` | Public-ingest HMAC secret (EC-617; must equal the |
1463
+ * | | deployment's `ELYTRA_INGEST_SECRET`) |
1256
1464
  *
1257
1465
  * EC-579 retired `STUDIO_ORIGIN` (the CORS origin of the studio's deleted browser
1258
1466
  * dispatcher): the one emitter is the Convex deployment, server to server.
@@ -1263,12 +1471,14 @@ declare const ELYTRA_HOST_ENV_KEYS: {
1263
1471
  readonly projectId: "ELYTRA_PROJECT_ID";
1264
1472
  readonly draftToken: "DRAFT_PREVIEW_TOKEN";
1265
1473
  readonly revalidateSecret: "REVALIDATE_SECRET";
1474
+ readonly ingestSecret: "INGEST_SECRET";
1266
1475
  };
1267
1476
  interface ElytraHostEnv {
1268
1477
  convexUrl?: string;
1269
1478
  projectId?: string;
1270
1479
  draftToken?: string;
1271
1480
  revalidateSecret?: string;
1481
+ ingestSecret?: string;
1272
1482
  }
1273
1483
  /**
1274
1484
  * Resolve the kit's env bag from a raw env record (`process.env` by default at
@@ -1585,6 +1795,12 @@ interface HostCanvasRouteOptions {
1585
1795
  * invalidation — a disagreement would drop nothing, silently.
1586
1796
  */
1587
1797
  published?: CanvasRouteOptions['published'];
1798
+ /**
1799
+ * EC-618 — the analytics slot (see `CanvasRouteOptions.analytics`): its value is an
1800
+ * addon's beacon (`@elytracms/analytics/next`, unpublished pending counsel). Absent →
1801
+ * nothing renders and nothing is sent.
1802
+ */
1803
+ analytics?: CanvasRouteOptions['analytics'];
1588
1804
  }
1589
1805
  interface LoadGlobalsOptions {
1590
1806
  /** Perspective of the chrome read. Default `'published'` (live is published-only). */
@@ -1705,6 +1921,15 @@ interface ElytraHost<Collections extends readonly CollectionDef[] = readonly Col
1705
1921
  previewRoute(): PreviewRouteHandlers;
1706
1922
  /** The draft-mode DISABLE route (no token — it only reduces visibility). */
1707
1923
  previewDisableRoute(): PreviewRouteHandlers;
1924
+ /**
1925
+ * EC-617 — the PUBLIC INGEST route for `app/api/ingest/route.ts`: the site's one write path
1926
+ * (form submissions, analytics events, upload URLs), signed with `INGEST_SECRET` and
1927
+ * forwarded to this deployment's receiver (its HTTP origin derived from
1928
+ * `ELYTRA_CONVEX_URL`; pass `siteUrl` for a self-hosted backend). Unset secret or no
1929
+ * backend = 503, closed. The rate limit, the client key and the bot gate are the host's to
1930
+ * tune — the engine supplies the slots, never a vendor.
1931
+ */
1932
+ ingestRoute(options?: Partial<Omit<IngestRouteOptions, 'secret'>>): IngestRouteHandlers;
1708
1933
  /**
1709
1934
  * Does the page for this catch-all `slug` OPEN full-bleed? Manifest-driven
1710
1935
  * (`presentation.fullBleed`, EC-368) and draft-mode aware — the host lays out
@@ -1870,4 +2095,4 @@ declare function evaluatePreviewRequest(requestUrl: string | URL, expectedToken:
1870
2095
  */
1871
2096
  declare const PACKAGE = "@elytracms/next";
1872
2097
 
1873
- export { type AssetImageLoaderOptions, type BakedListing, CANVAS_VALIDATION_SLUG, type CanvasCacheKey, type CanvasContentRequest, type CanvasPageOutcome, CanvasRenderer, type CanvasRendererProps, type CanvasRequestInfo, type CanvasRoute, type CanvasRouteOptions, type CanvasRouteProps, type CanvasSitemapOptions, type CanvasSitemapResult, type CanvasWrapContext, type ContentSnapshot, type ContentSnapshotOptions, type ContentSource, type CreateCanvasSitemapOptions, type DeliveryPresentation, ELYTRA_HOST_ENV_KEYS, type ElytraContentMode, type ElytraHost, type ElytraHostEnv, type ElytraHostImageOptions, type ElytraHostOptions, ElytraImage, type ElytraImageAsset, type ElytraImageProps, type GlobalsConventions, type HostCanvasRouteOptions, type HostComponent, type HostComponents, type LiveContentOptions, type LoadGlobalsOptions, type NextImageOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidateRouteHandlers, type RevalidateRouteOptions, type RevalidationProject, type SeoMetadataContext, type SiteGlobals, type SitemapSkippedRoute, type StaticContentData, assembleLiveSource, bodyItemsOf, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
2098
+ export { type AssetImageLoaderOptions, type BakedListing, type BotGateVerdict, CANVAS_VALIDATION_SLUG, type CanvasBeaconProps, type CanvasCacheKey, type CanvasContentRequest, type CanvasPageOutcome, CanvasRenderer, type CanvasRendererProps, type CanvasRequestInfo, type CanvasRoute, type CanvasRouteOptions, type CanvasRouteProps, type CanvasSitemapOptions, type CanvasSitemapResult, type CanvasWrapContext, type ContentSnapshot, type ContentSnapshotOptions, type ContentSource, type CreateCanvasSitemapOptions, type DeliveryPresentation, ELYTRA_HOST_ENV_KEYS, type ElytraContentMode, type ElytraHost, type ElytraHostEnv, type ElytraHostImageOptions, type ElytraHostOptions, ElytraImage, type ElytraImageAsset, type ElytraImageProps, type FixedWindowLimiter, type GlobalsConventions, type HostCanvasRouteOptions, type HostComponent, type HostComponents, type IngestBotGate, type IngestEnrich, type IngestEvaluation, type IngestRouteHandlers, type IngestRouteOptions, type LiveContentOptions, type LoadGlobalsOptions, type NextImageOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidateRouteHandlers, type RevalidateRouteOptions, type RevalidationProject, type SeoMetadataContext, type SiteGlobals, type SitemapSkippedRoute, type StaticContentData, assembleLiveSource, bodyItemsOf, canvasBeaconProps, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createFixedWindowLimiter, createIngestRoute, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defaultIngestClientKey, defineHostComponents, docCacheTag, elytraHostImage, evaluateIngestRequest, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, isCrossOrigin, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, readCappedBody, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
package/dist/index.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { registerComponents, createRenderContext, renderDocumentBodyItems, documentBodyItems, serializeListingQuery, documentBodyRichTextValues, documentBodyCompositionNodes, richTextEmbedsInValue, collectReferencedAssetIds, collectRichTextRefsInValue, collectReferencedRelations, collectReferencedListings, collectReferencedRichTextLinks, listingRequestOf } from '@elytracms/core/runtime-renderer';
2
2
  import { isBlockSequenceValueShape } from '@elytracms/core/rich-text';
3
- import { jsx, Fragment } from 'react/jsx-runtime';
3
+ import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
4
4
  import { cacheTags, isDocumentHistory, resolvedAsset, parseDeliveredDocuments, createContentClient, createContentContext } from '@elytracms/core/content';
5
5
  import { DEFAULT_SEO_FIELD, parseLocaleConfig, documentKey, liveRedirectRecords, SEO_SUBFIELDS, splitLocalePath, DRAFT_PERSPECTIVE, splitPath, localeUrlPath, variantOf, DRAFT_VARIANT, createRouter, presentationBodyFields, redirectRecordSchema } from '@elytracms/core/cms-core';
6
6
  export { DRAFT_PERSPECTIVE, documentSchema, localeConfigSchema, localeUrlPath, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection, variantOf } from '@elytracms/core/cms-core';
7
+ import { redirectRecordKind } from '@elytracms/core/persistence';
7
8
  import { revalidateTag, cacheTag } from 'next/cache';
8
9
  import { draftMode } from 'next/headers';
9
10
  import { redirect, notFound, permanentRedirect } from 'next/navigation';
10
11
  import { COMPOSITE_KEY_SEPARATOR } from '@elytracms/core/project-graph';
11
12
  import { verifyRevalidationSignature, readRevalidationBody, REVALIDATE_SIGNATURE_HEADER } from '@elytracms/core/revalidation';
12
13
  export { REVALIDATE_SIGNATURE_HEADER, revalidationEventSchema, signRevalidationBody, signedRevalidationRequest } from '@elytracms/core/revalidation';
14
+ import { INGEST_MAX_BODY_BYTES, readIngestPayload, INGEST_RECEIVER_PATH, signedIngestRequest, convexSiteUrl } from '@elytracms/core/ingest';
13
15
  import NextImage from 'next/image';
14
16
  import { normalizeImageSource } from '@elytracms/components';
15
17
  import { projectFrontendUrl, resolveProject } from '@elytracms/core/studio-core';
@@ -173,7 +175,7 @@ async function createContentSnapshot(adapter, projectId, options) {
173
175
  const documentRecords = await adapter.cms.listDocuments(projectId);
174
176
  const versionRecords = await adapter.cms.listAllDocumentVersions(projectId);
175
177
  const assetRecords = await adapter.assets.listAssets(projectId);
176
- const redirectRecords = await adapter.redirects.listRedirects(projectId);
178
+ const redirectRecords = await adapter.records(redirectRecordKind).list(projectId);
177
179
  const redirects = liveRedirectRecords(redirectRecords);
178
180
  const versionsByKey = /* @__PURE__ */ new Map();
179
181
  for (const record2 of versionRecords) {
@@ -283,6 +285,15 @@ function canvasWrapContext(result, info) {
283
285
  result
284
286
  };
285
287
  }
288
+ function canvasBeaconProps(result, info) {
289
+ const document = result.document;
290
+ return {
291
+ path: info.url,
292
+ status: 200,
293
+ locale: result.tags.locale,
294
+ ...document ? { collection: document.collection, documentId: document.id } : {}
295
+ };
296
+ }
286
297
  function pathFromSlug(slug) {
287
298
  return "/" + (slug ?? []).join("/");
288
299
  }
@@ -590,8 +601,13 @@ function createCanvasRoute(options) {
590
601
  presentation: outcome.presentation
591
602
  }
592
603
  );
593
- if (!options.wrap) return rendered;
594
- return options.wrap(rendered, canvasWrapContext(outcome.result, { url, perspective }));
604
+ const page = options.wrap ? options.wrap(rendered, canvasWrapContext(outcome.result, { url, perspective })) : rendered;
605
+ const Beacon = options.analytics;
606
+ if (!Beacon || perspective !== "published") return page;
607
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
608
+ page,
609
+ /* @__PURE__ */ jsx(Beacon, { ...canvasBeaconProps(outcome.result, { url}) })
610
+ ] });
595
611
  }
596
612
  }
597
613
  };
@@ -758,6 +774,177 @@ function createRevalidateRoute(options) {
758
774
  }
759
775
  };
760
776
  }
777
+ function createFixedWindowLimiter(options) {
778
+ const windowMs = options.windowMs ?? 6e4;
779
+ const now = options.now ?? Date.now;
780
+ const windows = /* @__PURE__ */ new Map();
781
+ return {
782
+ hit(key) {
783
+ const at = now();
784
+ for (const [held, window2] of windows) if (at - window2.windowStart >= windowMs) windows.delete(held);
785
+ const window = windows.get(key);
786
+ if (!window) {
787
+ windows.set(key, { windowStart: at, count: 1 });
788
+ return { allowed: true };
789
+ }
790
+ if (window.count >= options.limit) {
791
+ return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((window.windowStart + windowMs - at) / 1e3)) };
792
+ }
793
+ window.count += 1;
794
+ return { allowed: true };
795
+ },
796
+ size: () => windows.size
797
+ };
798
+ }
799
+ function defaultIngestClientKey(request) {
800
+ const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
801
+ if (forwarded) return forwarded;
802
+ const real = request.headers.get("x-real-ip")?.trim();
803
+ return real || "unknown";
804
+ }
805
+ function isCrossOrigin(request) {
806
+ const site = request.headers.get("sec-fetch-site");
807
+ if (site && site !== "same-origin" && site !== "none") return true;
808
+ const origin = request.headers.get("origin");
809
+ if (!origin) return false;
810
+ try {
811
+ return new URL(origin).origin !== new URL(request.url).origin;
812
+ } catch {
813
+ return true;
814
+ }
815
+ }
816
+ async function evaluateIngestRequest(input) {
817
+ const { request } = input;
818
+ if (request.method !== "POST") {
819
+ return { ok: false, status: 405, body: { error: "Only POST is accepted." }, headers: { allow: "POST" } };
820
+ }
821
+ if (!input.configured) {
822
+ return { ok: false, status: 503, body: { error: "Ingest is not configured on this site." } };
823
+ }
824
+ if (isCrossOrigin(request)) {
825
+ return { ok: false, status: 403, body: { error: "Cross-origin requests are not accepted." } };
826
+ }
827
+ const limited = input.limiter.hit(input.clientKey(request));
828
+ if (!limited.allowed) {
829
+ return {
830
+ ok: false,
831
+ status: 429,
832
+ body: { error: "Too many requests \u2014 try again shortly." },
833
+ headers: { "retry-after": String(limited.retryAfterSeconds) }
834
+ };
835
+ }
836
+ if (input.rawBody === void 0) {
837
+ return { ok: false, status: 413, body: { error: "The payload is too large." } };
838
+ }
839
+ const read = readIngestPayload(input.rawBody);
840
+ if (!read.ok) {
841
+ return { ok: false, status: 400, body: { error: read.message, ...read.issues ? { issues: read.issues } : {} } };
842
+ }
843
+ if (input.botGate) {
844
+ const verdict = await input.botGate({ request, payload: read.value });
845
+ if (!verdict.allow) {
846
+ return { ok: false, status: 403, body: { error: "Refused by the bot gate.", gate: "bot", reason: verdict.reason } };
847
+ }
848
+ }
849
+ if (!input.enrich) return { ok: true, payload: read.value };
850
+ return enrichPayload(input.enrich, request, read.value);
851
+ }
852
+ async function enrichPayload(enrich, request, payload) {
853
+ let enriched;
854
+ try {
855
+ enriched = await enrich({ request, payload });
856
+ } catch (error) {
857
+ const message = error instanceof Error ? error.message : String(error);
858
+ return { ok: false, status: 500, body: { error: `The site's ingest enrichment failed: ${message}` } };
859
+ }
860
+ const body = JSON.stringify(enriched ?? null);
861
+ const reread = readIngestPayload(body);
862
+ if (!reread.ok) {
863
+ return {
864
+ ok: false,
865
+ status: 500,
866
+ body: { error: "The site's ingest enrichment returned an invalid payload.", ...reread.issues ? { issues: reread.issues } : {} }
867
+ };
868
+ }
869
+ if (new TextEncoder().encode(body).byteLength > INGEST_MAX_BODY_BYTES) {
870
+ return { ok: false, status: 500, body: { error: "The site's ingest enrichment grew the payload past the receiver's cap." } };
871
+ }
872
+ return { ok: true, payload: reread.value };
873
+ }
874
+ async function readCappedBody(request, maxBytes = INGEST_MAX_BODY_BYTES) {
875
+ const declared = Number(request.headers.get("content-length"));
876
+ if (Number.isFinite(declared) && declared > maxBytes) return void 0;
877
+ if (!request.body) return "";
878
+ const reader = request.body.getReader();
879
+ const chunks = [];
880
+ let total = 0;
881
+ for (; ; ) {
882
+ const { done, value } = await reader.read();
883
+ if (done) break;
884
+ total += value.byteLength;
885
+ if (total > maxBytes) {
886
+ await reader.cancel().catch(() => void 0);
887
+ return void 0;
888
+ }
889
+ chunks.push(value);
890
+ }
891
+ const bytes = new Uint8Array(total);
892
+ let offset = 0;
893
+ for (const chunk of chunks) {
894
+ bytes.set(chunk, offset);
895
+ offset += chunk.byteLength;
896
+ }
897
+ return new TextDecoder().decode(bytes);
898
+ }
899
+ var NO_STORE = { "cache-control": "no-store" };
900
+ function createIngestRoute(options) {
901
+ const maxBodyBytes = options.maxBodyBytes ?? INGEST_MAX_BODY_BYTES;
902
+ if (maxBodyBytes > INGEST_MAX_BODY_BYTES || maxBodyBytes <= 0) {
903
+ throw new Error(`createIngestRoute: maxBodyBytes must be between 1 and ${INGEST_MAX_BODY_BYTES} (the receiver's cap).`);
904
+ }
905
+ const limiter = createFixedWindowLimiter({
906
+ limit: options.maxRequestsPerMinute ?? 30,
907
+ ...options.now ? { now: options.now } : {}
908
+ });
909
+ const clientKey = options.clientKey ?? defaultIngestClientKey;
910
+ const receiver = options.siteUrl ? `${options.siteUrl.replace(/\/+$/, "")}${INGEST_RECEIVER_PATH}` : void 0;
911
+ const fetchImpl = options.fetchImpl ?? fetch;
912
+ return {
913
+ async POST(request) {
914
+ const rawBody = request.method === "POST" ? await readCappedBody(request, maxBodyBytes) : "";
915
+ const evaluation = await evaluateIngestRequest({
916
+ request,
917
+ rawBody,
918
+ configured: options.secret !== void 0 && options.secret.length > 0 && receiver !== void 0,
919
+ limiter,
920
+ clientKey,
921
+ ...options.botGate ? { botGate: options.botGate } : {},
922
+ ...options.enrich ? { enrich: options.enrich } : {}
923
+ });
924
+ if (!evaluation.ok) {
925
+ return Response.json(evaluation.body, { status: evaluation.status, headers: { ...NO_STORE, ...evaluation.headers } });
926
+ }
927
+ const signed = await signedIngestRequest(evaluation.payload, options.secret);
928
+ let answer;
929
+ try {
930
+ answer = await fetchImpl(receiver, { method: "POST", headers: signed.headers, body: signed.body });
931
+ } catch {
932
+ return Response.json({ error: "The ingest receiver could not be reached." }, { status: 502, headers: NO_STORE });
933
+ }
934
+ if (answer.status === 401) {
935
+ return Response.json(
936
+ { error: "The ingest receiver refused this site's signature \u2014 INGEST_SECRET must equal the deployment's ELYTRA_INGEST_SECRET." },
937
+ { status: 502, headers: NO_STORE }
938
+ );
939
+ }
940
+ const text = await answer.text();
941
+ return new Response(text, {
942
+ status: answer.status,
943
+ headers: { ...NO_STORE, "content-type": answer.headers.get("content-type") ?? "application/json" }
944
+ });
945
+ }
946
+ };
947
+ }
761
948
  var LQIP_MAX_EDGE = 24;
762
949
  function fitLqipLongEdge(width, height) {
763
950
  const scale = Math.min(1, LQIP_MAX_EDGE / Math.max(width, height));
@@ -866,7 +1053,8 @@ var ELYTRA_HOST_ENV_KEYS = {
866
1053
  convexUrl: "ELYTRA_CONVEX_URL",
867
1054
  projectId: "ELYTRA_PROJECT_ID",
868
1055
  draftToken: "DRAFT_PREVIEW_TOKEN",
869
- revalidateSecret: "REVALIDATE_SECRET"
1056
+ revalidateSecret: "REVALIDATE_SECRET",
1057
+ ingestSecret: "INGEST_SECRET"
870
1058
  };
871
1059
  function cleaned(value) {
872
1060
  const trimmed = value?.trim();
@@ -1136,8 +1324,6 @@ function createPreviewDisableRoute() {
1136
1324
  }
1137
1325
  };
1138
1326
  }
1139
-
1140
- // src/host-kit.ts
1141
1327
  function resolveHostProject(entry) {
1142
1328
  const result = resolveProject(entry);
1143
1329
  if (result.ok) return result.project;
@@ -1238,6 +1424,11 @@ function createElytraHost(options) {
1238
1424
  });
1239
1425
  const previewRoute = () => createPreviewRoute({ token: env.draftToken });
1240
1426
  const previewDisableRoute = () => createPreviewDisableRoute();
1427
+ const ingestRoute = (overrides = {}) => createIngestRoute({
1428
+ ...overrides,
1429
+ secret: env.ingestSecret,
1430
+ siteUrl: "siteUrl" in overrides ? overrides.siteUrl : convexSiteUrl(env.convexUrl)
1431
+ });
1241
1432
  const pageOpensFullBleed = async (slug) => {
1242
1433
  const perspective = await requestPerspective();
1243
1434
  const source = await loadContent({ perspective });
@@ -1263,11 +1454,12 @@ function createElytraHost(options) {
1263
1454
  revalidateRoute,
1264
1455
  previewRoute,
1265
1456
  previewDisableRoute,
1457
+ ingestRoute,
1266
1458
  pageOpensFullBleed
1267
1459
  };
1268
1460
  }
1269
1461
  var PACKAGE = "@elytracms/next";
1270
1462
 
1271
- export { CANVAS_VALIDATION_SLUG, CanvasRenderer, ELYTRA_HOST_ENV_KEYS, ElytraImage, PACKAGE, assembleLiveSource, bodyItemsOf, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
1463
+ export { CANVAS_VALIDATION_SLUG, CanvasRenderer, ELYTRA_HOST_ENV_KEYS, ElytraImage, PACKAGE, assembleLiveSource, bodyItemsOf, canvasBeaconProps, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createFixedWindowLimiter, createIngestRoute, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defaultIngestClientKey, defineHostComponents, docCacheTag, elytraHostImage, evaluateIngestRequest, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, isCrossOrigin, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, readCappedBody, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
1272
1464
  //# sourceMappingURL=index.js.map
1273
1465
  //# sourceMappingURL=index.js.map