@easy-cms/core 0.16.1 → 0.18.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.
@@ -1699,6 +1706,13 @@ interface RestHandlerOptions {
1699
1706
  type RestHandler = (request: Request) => Promise<Response>;
1700
1707
  /** Creates the REST API as a Web-standard `(Request) => Response` handler. */
1701
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>;
1702
1716
  declare function readCookie(request: Request, name: string): string | undefined;
1703
1717
 
1704
1718
  /** Best-effort English singular: "categories" → "category". */
@@ -1715,4 +1729,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1715
1729
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1716
1730
  declare function validateConfig(config: Config): ConfigIssue[];
1717
1731
 
1718
- 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, 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
@@ -2016,18 +2016,31 @@ function validateEndpoints(config, collectionSlugs, add) {
2016
2016
  add(path, "must be an object { path, method, handler }");
2017
2017
  return;
2018
2018
  }
2019
- 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");
2020
2022
  const segments = typeof route2 === "string" ? route2.split("/").slice(1) : [];
2021
2023
  if (typeof route2 !== "string" || !route2.startsWith("/") || segments.length === 0 || !segments.every((s) => ENDPOINT_SEGMENT.test(s))) {
2022
2024
  add(`${path}.path`, `must be a path like "/seo/generate" (got ${JSON.stringify(route2)})`);
2023
2025
  } else {
2024
- const root = segments[0];
2025
- if (root.startsWith(":")) {
2026
+ const first = segments[0];
2027
+ if (first.startsWith(":")) {
2026
2028
  add(`${path}.path`, "must start with a fixed segment, not a parameter");
2027
- } 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)) {
2028
2041
  add(
2029
2042
  `${path}.path`,
2030
- `"/${root}" is used by the built-in API`,
2043
+ `"/${first}" is used by the built-in API`,
2031
2044
  'start the path with your plugin or feature name, e.g. "/seo/generate"'
2032
2045
  );
2033
2046
  }
@@ -2041,7 +2054,7 @@ function validateEndpoints(config, collectionSlugs, add) {
2041
2054
  }
2042
2055
  if (typeof handler !== "function") add(`${path}.handler`, "must be a function");
2043
2056
  if (typeof route2 === "string" && typeof verb === "string") {
2044
- 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, ":")}`;
2045
2058
  if (seen.has(key2)) add(`${path}.path`, `duplicate endpoint ${verb.toUpperCase()} ${route2}`);
2046
2059
  seen.add(key2);
2047
2060
  }
@@ -4358,6 +4371,42 @@ function createRestHandler(instance, options = {}) {
4358
4371
  const cms = instance;
4359
4372
  const basePath = (options.basePath ?? cms.config.routes.api).replace(/\/+$/, "");
4360
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) {
4361
4410
  return async (request) => {
4362
4411
  const cors = corsHeaders(cms, request);
4363
4412
  if (request.method.toUpperCase() === "OPTIONS") {
@@ -4365,31 +4414,29 @@ function createRestHandler(instance, options = {}) {
4365
4414
  }
4366
4415
  return withHeaders(await handle(request), cors);
4367
4416
  };
4368
- async function handle(request) {
4369
- const headers = new Headers({
4370
- "content-type": "application/json; charset=utf-8",
4371
- "cache-control": "no-store",
4372
- "x-content-type-options": "nosniff"
4373
- });
4374
- try {
4375
- const url = new URL(request.url);
4376
- if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
4377
- throw new HttpError("Not found", 404);
4378
- }
4379
- const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map((s) => decodeURIComponent(s));
4380
- const { token, via } = readToken(request);
4381
- const user = token ? await cms.auth.verify(token) : null;
4382
- if (!user && via === "bearer" && token?.startsWith("ecms_"))
4383
- throw new UnauthorizedError("Invalid or expired API key");
4384
- const ctx = { request, url, user, via: user ? via : null, token, headers };
4385
- const method = request.method.toUpperCase();
4386
- if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
4387
- const result = await customEndpoint(cms, ctx, method, segments) ?? await route(cms, ctx, method, segments, options);
4388
- if (result.body instanceof Response) return result.body;
4389
- return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
4390
- } catch (error) {
4391
- return errorResponse(cms, error, headers, production);
4392
- }
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);
4393
4440
  }
4394
4441
  }
4395
4442
  var CORS_METHODS = "GET, HEAD, POST, PATCH, PUT, DELETE";
@@ -4422,9 +4469,10 @@ function withHeaders(response, extra) {
4422
4469
  headers
4423
4470
  });
4424
4471
  }
4425
- async function customEndpoint(cms, ctx, method, segments) {
4472
+ async function customEndpoint(cms, ctx, method, segments, root) {
4426
4473
  let pathMatched = false;
4427
- 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));
4428
4476
  for (const endpoint of endpoints) {
4429
4477
  const params = matchPath(endpoint.path, segments);
4430
4478
  if (!params) continue;
@@ -4442,7 +4490,7 @@ async function customEndpoint(cms, ctx, method, segments) {
4442
4490
  return { body: body === void 0 ? null : body };
4443
4491
  }
4444
4492
  if (pathMatched) {
4445
- 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());
4446
4494
  throw methodNotAllowed(ctx, [...new Set(allow)].join(", "));
4447
4495
  }
4448
4496
  return void 0;
@@ -5122,6 +5170,7 @@ export {
5122
5170
  copyDatabase,
5123
5171
  createEasyCMS,
5124
5172
  createRestHandler,
5173
+ createRootEndpointHandler,
5125
5174
  defineConfig,
5126
5175
  fillMissing,
5127
5176
  findConfigFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",