@easy-cms/core 0.16.0 → 0.17.0

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
@@ -1275,10 +1275,17 @@ interface EndpointRequest {
1275
1275
  interface Endpoint {
1276
1276
  /**
1277
1277
  * Path under the API, e.g. `/seo/generate` or `/stats/:collection`. The first segment must
1278
- * not be a collection slug or one of `users`, `globals`, `admin`, `jobs`.
1278
+ * not be a collection slug or one of `users`, `globals`, `admin`, `jobs`. With `root`, the
1279
+ * path from the site's root, outside the API and the admin.
1279
1280
  */
1280
1281
  readonly path: string;
1281
1282
  readonly method: 'get' | 'post' | 'put' | 'patch' | 'delete';
1283
+ /**
1284
+ * Serve the path from the site's root instead of under `routes.api`, e.g. `/robots.txt`.
1285
+ * Only the standalone server (`easy-cms serve`) serves these; a Nuxt or Next.js app owns its
1286
+ * root, so plugins also offer a helper to use in the app's own route. Default false.
1287
+ */
1288
+ readonly root?: boolean;
1282
1289
  /**
1283
1290
  * Returns a `Response`, or a value that is sent as JSON. Throw an Easy CMS error
1284
1291
  * (`UnauthorizedError`, `ForbiddenError`, `ValidationError`, `NotFoundError`) for error responses.
@@ -1388,6 +1395,16 @@ declare const MEDIA = "media";
1388
1395
  /** Collections Easy CMS uses internally. Not exposed over REST or in the admin UI. */
1389
1396
  declare const INTERNAL_COLLECTIONS: ReadonlySet<string>;
1390
1397
 
1398
+ /**
1399
+ * A string that is the same for configs with the same structure: collections, fields, options
1400
+ * and every other plain value. Functions count only by where they are, not by their code, since
1401
+ * bundlers rename variables differently each time they compile the same source.
1402
+ *
1403
+ * Frameworks may load the config module more than once (Next.js bundles it into each server
1404
+ * layer), which gives different objects for the same config; this tells those apart from edits.
1405
+ */
1406
+ declare function configSignature(config: Config): string;
1407
+
1391
1408
  interface CopyProgress {
1392
1409
  readonly table: string;
1393
1410
  readonly rows: number;
@@ -1689,6 +1706,13 @@ interface RestHandlerOptions {
1689
1706
  type RestHandler = (request: Request) => Promise<Response>;
1690
1707
  /** Creates the REST API as a Web-standard `(Request) => Response` handler. */
1691
1708
  declare function createRestHandler<C extends Config>(instance: EasyCMS<C>, options?: RestHandlerOptions): RestHandler;
1709
+ /**
1710
+ * Serves the endpoints marked `root: true` (e.g. `/robots.txt`), with the same auth, CSRF and
1711
+ * errors as the REST API. Resolves to `undefined` when no root endpoint has the request's path,
1712
+ * so a server can go on to its other routes. Nuxt and Next.js apps own their root and don't
1713
+ * use it; the standalone server does.
1714
+ */
1715
+ declare function createRootEndpointHandler<C extends Config>(instance: EasyCMS<C>, options?: Pick<RestHandlerOptions, 'getClientIp'>): (request: Request) => Promise<Response | undefined>;
1692
1716
  declare function readCookie(request: Request, name: string): string | undefined;
1693
1717
 
1694
1718
  /** Best-effort English singular: "categories" → "category". */
@@ -1705,4 +1729,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1705
1729
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1706
1730
  declare function validateConfig(config: Config): ConfigIssue[];
1707
1731
 
1708
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
1732
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
package/dist/index.js CHANGED
@@ -915,6 +915,31 @@ function defineConfig(config) {
915
915
  return config;
916
916
  }
917
917
 
918
+ // src/config-signature.ts
919
+ function configSignature(config) {
920
+ const known = signatures.get(config);
921
+ if (known !== void 0) return known;
922
+ const seen = /* @__PURE__ */ new WeakSet();
923
+ const signature = JSON.stringify(config, (_key, value) => {
924
+ if (typeof value === "function") return "fn";
925
+ if (typeof value === "object" && value !== null) {
926
+ if (seen.has(value)) return "[circular]";
927
+ seen.add(value);
928
+ if (!Array.isArray(value)) {
929
+ const sorted = {};
930
+ for (const key2 of Object.keys(value).sort())
931
+ sorted[key2] = value[key2];
932
+ seen.add(sorted);
933
+ return sorted;
934
+ }
935
+ }
936
+ return value;
937
+ });
938
+ signatures.set(config, signature);
939
+ return signature;
940
+ }
941
+ var signatures = /* @__PURE__ */ new WeakMap();
942
+
918
943
  // src/copy.ts
919
944
  var BATCH = 200;
920
945
  async function copyDatabase(source, target, options = {}) {
@@ -1991,18 +2016,31 @@ function validateEndpoints(config, collectionSlugs, add) {
1991
2016
  add(path, "must be an object { path, method, handler }");
1992
2017
  return;
1993
2018
  }
1994
- const { path: route2, method, handler } = endpoint;
2019
+ const { path: route2, method, handler, root } = endpoint;
2020
+ if (root !== void 0 && typeof root !== "boolean")
2021
+ add(`${path}.root`, "must be true or false");
1995
2022
  const segments = typeof route2 === "string" ? route2.split("/").slice(1) : [];
1996
2023
  if (typeof route2 !== "string" || !route2.startsWith("/") || segments.length === 0 || !segments.every((s) => ENDPOINT_SEGMENT.test(s))) {
1997
2024
  add(`${path}.path`, `must be a path like "/seo/generate" (got ${JSON.stringify(route2)})`);
1998
2025
  } else {
1999
- const root = segments[0];
2000
- if (root.startsWith(":")) {
2026
+ const first = segments[0];
2027
+ if (first.startsWith(":")) {
2001
2028
  add(`${path}.path`, "must start with a fixed segment, not a parameter");
2002
- } else if (RESERVED_ENDPOINT_ROOTS.has(root) || collectionSlugs.has(root)) {
2029
+ } else if (root === true) {
2030
+ const taken = [
2031
+ config.routes?.api ?? "/api/cms",
2032
+ `/${(config.admin?.path ?? "/admin").replace(/^\/+|\/+$/g, "")}`,
2033
+ "/healthz"
2034
+ ].map((p) => p.replace(/\/+$/, "")).find((p) => route2 === p || route2.startsWith(`${p}/`) || p.startsWith(`${route2}/`));
2035
+ if (taken)
2036
+ add(
2037
+ `${path}.path`,
2038
+ `"${route2}" is served by ${taken === "/healthz" ? "the standalone server" : `"${taken}"`}`
2039
+ );
2040
+ } else if (RESERVED_ENDPOINT_ROOTS.has(first) || collectionSlugs.has(first)) {
2003
2041
  add(
2004
2042
  `${path}.path`,
2005
- `"/${root}" is used by the built-in API`,
2043
+ `"/${first}" is used by the built-in API`,
2006
2044
  'start the path with your plugin or feature name, e.g. "/seo/generate"'
2007
2045
  );
2008
2046
  }
@@ -2016,7 +2054,7 @@ function validateEndpoints(config, collectionSlugs, add) {
2016
2054
  }
2017
2055
  if (typeof handler !== "function") add(`${path}.handler`, "must be a function");
2018
2056
  if (typeof route2 === "string" && typeof verb === "string") {
2019
- const key2 = `${verb} ${route2.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, ":")}`;
2057
+ const key2 = `${root === true ? "root " : ""}${verb} ${route2.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, ":")}`;
2020
2058
  if (seen.has(key2)) add(`${path}.path`, `duplicate endpoint ${verb.toUpperCase()} ${route2}`);
2021
2059
  seen.add(key2);
2022
2060
  }
@@ -4333,6 +4371,42 @@ function createRestHandler(instance, options = {}) {
4333
4371
  const cms = instance;
4334
4372
  const basePath = (options.basePath ?? cms.config.routes.api).replace(/\/+$/, "");
4335
4373
  const production = process.env.NODE_ENV === "production";
4374
+ return withCors(cms, (request) => {
4375
+ const url = new URL(request.url);
4376
+ const inside = url.pathname === basePath || url.pathname.startsWith(`${basePath}/`);
4377
+ return respond(
4378
+ cms,
4379
+ request,
4380
+ url,
4381
+ inside ? url.pathname.slice(basePath.length) : null,
4382
+ false,
4383
+ options,
4384
+ production
4385
+ );
4386
+ });
4387
+ }
4388
+ function createRootEndpointHandler(instance, options = {}) {
4389
+ const cms = instance;
4390
+ const production = process.env.NODE_ENV === "production";
4391
+ const handler = withCors(cms, (request) => {
4392
+ const url = new URL(request.url);
4393
+ return respond(cms, request, url, url.pathname, true, options, production);
4394
+ });
4395
+ return async (request) => {
4396
+ let segments;
4397
+ try {
4398
+ segments = pathSegments(new URL(request.url).pathname);
4399
+ } catch {
4400
+ return void 0;
4401
+ }
4402
+ const matched = cms.config.endpoints.some((e) => e.root && matchPath(e.path, segments));
4403
+ return matched ? handler(request) : void 0;
4404
+ };
4405
+ }
4406
+ function pathSegments(path) {
4407
+ return path.split("/").filter(Boolean).map((s) => decodeURIComponent(s));
4408
+ }
4409
+ function withCors(cms, handle) {
4336
4410
  return async (request) => {
4337
4411
  const cors = corsHeaders(cms, request);
4338
4412
  if (request.method.toUpperCase() === "OPTIONS") {
@@ -4340,31 +4414,29 @@ function createRestHandler(instance, options = {}) {
4340
4414
  }
4341
4415
  return withHeaders(await handle(request), cors);
4342
4416
  };
4343
- async function handle(request) {
4344
- const headers = new Headers({
4345
- "content-type": "application/json; charset=utf-8",
4346
- "cache-control": "no-store",
4347
- "x-content-type-options": "nosniff"
4348
- });
4349
- try {
4350
- const url = new URL(request.url);
4351
- if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
4352
- throw new HttpError("Not found", 404);
4353
- }
4354
- const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map((s) => decodeURIComponent(s));
4355
- const { token, via } = readToken(request);
4356
- const user = token ? await cms.auth.verify(token) : null;
4357
- if (!user && via === "bearer" && token?.startsWith("ecms_"))
4358
- throw new UnauthorizedError("Invalid or expired API key");
4359
- const ctx = { request, url, user, via: user ? via : null, token, headers };
4360
- const method = request.method.toUpperCase();
4361
- if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
4362
- const result = await customEndpoint(cms, ctx, method, segments) ?? await route(cms, ctx, method, segments, options);
4363
- if (result.body instanceof Response) return result.body;
4364
- return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
4365
- } catch (error) {
4366
- return errorResponse(cms, error, headers, production);
4367
- }
4417
+ }
4418
+ async function respond(cms, request, url, path, root, options, production) {
4419
+ const headers = new Headers({
4420
+ "content-type": "application/json; charset=utf-8",
4421
+ "cache-control": "no-store",
4422
+ "x-content-type-options": "nosniff"
4423
+ });
4424
+ try {
4425
+ if (path === null) throw new HttpError("Not found", 404);
4426
+ const segments = pathSegments(path);
4427
+ const { token, via } = readToken(request);
4428
+ const user = token ? await cms.auth.verify(token) : null;
4429
+ if (!user && via === "bearer" && token?.startsWith("ecms_"))
4430
+ throw new UnauthorizedError("Invalid or expired API key");
4431
+ const ctx = { request, url, user, via: user ? via : null, token, headers };
4432
+ const method = request.method.toUpperCase();
4433
+ if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
4434
+ const result = root ? await customEndpoint(cms, ctx, method, segments, true) : await customEndpoint(cms, ctx, method, segments, false) ?? await route(cms, ctx, method, segments, options);
4435
+ if (!result) throw new HttpError("Not found", 404);
4436
+ if (result.body instanceof Response) return result.body;
4437
+ return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
4438
+ } catch (error) {
4439
+ return errorResponse(cms, error, headers, production);
4368
4440
  }
4369
4441
  }
4370
4442
  var CORS_METHODS = "GET, HEAD, POST, PATCH, PUT, DELETE";
@@ -4397,9 +4469,10 @@ function withHeaders(response, extra) {
4397
4469
  headers
4398
4470
  });
4399
4471
  }
4400
- async function customEndpoint(cms, ctx, method, segments) {
4472
+ async function customEndpoint(cms, ctx, method, segments, root) {
4401
4473
  let pathMatched = false;
4402
- const endpoints = [...cms.config.endpoints].sort((a, b) => fixedSegments(b) - fixedSegments(a));
4474
+ const own = cms.config.endpoints.filter((e) => !!e.root === root);
4475
+ const endpoints = [...own].sort((a, b) => fixedSegments(b) - fixedSegments(a));
4403
4476
  for (const endpoint of endpoints) {
4404
4477
  const params = matchPath(endpoint.path, segments);
4405
4478
  if (!params) continue;
@@ -4417,7 +4490,7 @@ async function customEndpoint(cms, ctx, method, segments) {
4417
4490
  return { body: body === void 0 ? null : body };
4418
4491
  }
4419
4492
  if (pathMatched) {
4420
- const allow = cms.config.endpoints.filter((e) => matchPath(e.path, segments)).map((e) => e.method.toUpperCase());
4493
+ const allow = own.filter((e) => matchPath(e.path, segments)).map((e) => e.method.toUpperCase());
4421
4494
  throw methodNotAllowed(ctx, [...new Set(allow)].join(", "));
4422
4495
  }
4423
4496
  return void 0;
@@ -5092,10 +5165,12 @@ export {
5092
5165
  anyone,
5093
5166
  applyDefaults,
5094
5167
  applyPlugins,
5168
+ configSignature,
5095
5169
  consoleLogger,
5096
5170
  copyDatabase,
5097
5171
  createEasyCMS,
5098
5172
  createRestHandler,
5173
+ createRootEndpointHandler,
5099
5174
  defineConfig,
5100
5175
  fillMissing,
5101
5176
  findConfigFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",