@easy-cms/core 0.14.0 → 0.16.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +971 -882
  2. package/dist/index.js +259 -17
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,6 +3,132 @@ var anyone = () => true;
3
3
  var isLoggedIn = ({ user }) => user !== null;
4
4
  var isAdmin = ({ user }) => user?.role === "admin";
5
5
 
6
+ // src/api-keys.ts
7
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
8
+ var API_KEYS = "api-keys";
9
+ var API_KEY_OPERATIONS = ["read", "create", "update", "delete", "publish"];
10
+ var API_KEY_GLOBAL_OPERATIONS = ["read", "update", "publish"];
11
+ var API_KEY_EXCLUDED = /* @__PURE__ */ new Set(["users", API_KEYS]);
12
+ var KEY_PATTERN = /^ecms_([0-9a-f]{8})_([A-Za-z0-9_-]{32,})$/;
13
+ function isApiKey(token) {
14
+ return token.startsWith("ecms_");
15
+ }
16
+ function newApiKey() {
17
+ const prefix = randomBytes(4).toString("hex");
18
+ const secret = randomBytes(32).toString("base64url");
19
+ return { key: `ecms_${prefix}_${secret}`, prefix, hash: hashSecret(secret) };
20
+ }
21
+ function hashSecret(secret) {
22
+ return createHash("sha256").update(secret).digest("hex");
23
+ }
24
+ function parseApiKey(token) {
25
+ const match = KEY_PATTERN.exec(token);
26
+ return match ? { prefix: match[1], secret: match[2] } : null;
27
+ }
28
+ function secretMatches(secret, hash) {
29
+ if (typeof hash !== "string") return false;
30
+ const a = Buffer.from(hashSecret(secret), "hex");
31
+ const b = Buffer.from(hash, "hex");
32
+ return a.length === b.length && timingSafeEqual(a, b);
33
+ }
34
+ function keyAllows(user, target, operation) {
35
+ const key2 = user?.apiKey;
36
+ if (!key2) return true;
37
+ if ("collection" in target) {
38
+ if (API_KEY_EXCLUDED.has(target.collection)) return false;
39
+ return key2.permissions.collections?.[target.collection]?.includes(operation) ?? false;
40
+ }
41
+ const allowed2 = key2.permissions.globals?.[target.global];
42
+ return allowed2?.includes(operation) ?? false;
43
+ }
44
+ var isAdmin2 = (user) => user?.role === "admin";
45
+ var ownKeys = ({ user }) => !user || user.apiKey ? false : isAdmin2(user) ? true : { user: { equals: user.id } };
46
+ var system = { update: () => false };
47
+ var apiKeysCollection = {
48
+ slug: API_KEYS,
49
+ labels: {
50
+ singular: { en: "API key", th: "API key" },
51
+ plural: { en: "API keys", th: "API keys" }
52
+ },
53
+ icon: "key",
54
+ useAsTitle: "name",
55
+ access: {
56
+ read: ownKeys,
57
+ // Through `createApiKey` (POST <api>/api-keys), which generates the secret.
58
+ create: ({ user }) => !!user && !user.apiKey,
59
+ update: ownKeys,
60
+ delete: ownKeys
61
+ },
62
+ fields: [
63
+ { name: "name", type: "text", required: true, label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
64
+ {
65
+ name: "permissions",
66
+ type: "json",
67
+ label: { en: "Permissions", th: "\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C" },
68
+ admin: { component: "ecms-api-key-permissions" }
69
+ },
70
+ {
71
+ name: "expiresAt",
72
+ type: "date",
73
+ label: { en: "Expires", th: "\u0E2B\u0E21\u0E14\u0E2D\u0E32\u0E22\u0E38" },
74
+ position: "sidebar"
75
+ },
76
+ {
77
+ name: "prefix",
78
+ type: "text",
79
+ index: true,
80
+ label: { en: "Key starts with", th: "\u0E02\u0E36\u0E49\u0E19\u0E15\u0E49\u0E19\u0E14\u0E49\u0E27\u0E22" },
81
+ position: "sidebar",
82
+ access: system
83
+ },
84
+ {
85
+ name: "user",
86
+ type: "relationship",
87
+ to: "users",
88
+ label: { en: "Owner", th: "\u0E40\u0E08\u0E49\u0E32\u0E02\u0E2D\u0E07" },
89
+ position: "sidebar",
90
+ access: system
91
+ },
92
+ {
93
+ name: "lastUsedAt",
94
+ type: "date",
95
+ label: { en: "Last used", th: "\u0E43\u0E0A\u0E49\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14" },
96
+ position: "sidebar",
97
+ access: system
98
+ },
99
+ { name: "keyHash", type: "text", hidden: true }
100
+ ],
101
+ hooks: {
102
+ // Only real collections, globals and operations are kept.
103
+ beforeChange: [
104
+ ({ data, cms }) => data.permissions === void 0 ? data : {
105
+ ...data,
106
+ permissions: cleanPermissions(data.permissions, {
107
+ collections: cms.config.collections.map((c) => c.slug),
108
+ globals: cms.config.globals.map((g) => g.slug)
109
+ })
110
+ }
111
+ ]
112
+ }
113
+ };
114
+ function cleanPermissions(value, known) {
115
+ const input = typeof value === "object" && value !== null ? value : {};
116
+ const pick = (group, slugs, operations) => {
117
+ const out = {};
118
+ if (typeof group !== "object" || group === null) return out;
119
+ for (const [slug, ops] of Object.entries(group)) {
120
+ if (!slugs.includes(slug) || API_KEY_EXCLUDED.has(slug) || !Array.isArray(ops)) continue;
121
+ const valid = operations.filter((op) => ops.includes(op));
122
+ if (valid.length) out[slug] = valid;
123
+ }
124
+ return out;
125
+ };
126
+ return {
127
+ collections: pick(input.collections, known.collections, API_KEY_OPERATIONS),
128
+ globals: pick(input.globals, known.globals, API_KEY_GLOBAL_OPERATIONS)
129
+ };
130
+ }
131
+
6
132
  // src/errors.ts
7
133
  var ConfigError = class _ConfigError extends Error {
8
134
  issues;
@@ -412,7 +538,7 @@ var webhookDeliveriesCollection = {
412
538
  };
413
539
 
414
540
  // src/auth/password.ts
415
- import { randomBytes, scrypt, timingSafeEqual } from "crypto";
541
+ import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
416
542
  var MIN_PASSWORD_LENGTH = 8;
417
543
  var N = 2 ** 17;
418
544
  var R = 8;
@@ -431,7 +557,7 @@ function derive(password, salt, n, r, p, length) {
431
557
  });
432
558
  }
433
559
  async function hashPassword(password) {
434
- const salt = randomBytes(16);
560
+ const salt = randomBytes2(16);
435
561
  const key2 = await derive(password, salt, N, R, P, KEY_LENGTH);
436
562
  return ["scrypt", N, R, P, salt.toString("base64url"), key2.toString("base64url")].join("$");
437
563
  }
@@ -447,7 +573,7 @@ async function verifyPassword(password, stored) {
447
573
  Number(p),
448
574
  expected.length
449
575
  );
450
- return key2.length === expected.length && timingSafeEqual(key2, expected);
576
+ return key2.length === expected.length && timingSafeEqual2(key2, expected);
451
577
  }
452
578
  var dummyHash;
453
579
  async function fakeVerify(password) {
@@ -457,12 +583,12 @@ async function fakeVerify(password) {
457
583
  }
458
584
 
459
585
  // src/auth/tokens.ts
460
- import { createHash, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
586
+ import { createHash as createHash2, createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
461
587
  function newToken() {
462
- return randomBytes2(32).toString("base64url");
588
+ return randomBytes3(32).toString("base64url");
463
589
  }
464
590
  function hashToken(token) {
465
- return createHash("sha256").update(token).digest("hex");
591
+ return createHash2("sha256").update(token).digest("hex");
466
592
  }
467
593
  function hmac(secret, value) {
468
594
  return createHmac("sha256", secret).update(value).digest("base64url");
@@ -482,7 +608,7 @@ function csrfToken(secret, token) {
482
608
  function safeEqual(a, b) {
483
609
  const left = Buffer.from(a);
484
610
  const right = Buffer.from(b);
485
- return left.length === right.length && timingSafeEqual2(left, right);
611
+ return left.length === right.length && timingSafeEqual3(left, right);
486
612
  }
487
613
  function signPreviewToken(secret, target, expiresAt) {
488
614
  const payload = Buffer.from(
@@ -568,9 +694,13 @@ var Auth = class {
568
694
  const session = await this.findSession(token);
569
695
  if (session) await this.db.delete({ collection: SESSIONS, id: session.id });
570
696
  }
571
- /** Returns the user for a signed session token, or `null` if it is invalid or expired. */
697
+ /**
698
+ * Returns the user for a signed session token, or for an API key (`ecms_…`, with `apiKey` set
699
+ * on the user), or `null` if it is invalid or expired.
700
+ */
572
701
  async verify(signedToken) {
573
702
  if (!signedToken) return null;
703
+ if (isApiKey(signedToken)) return this.verifyApiKey(signedToken);
574
704
  const token = unsignToken(this.config.secret, signedToken);
575
705
  if (!token) return null;
576
706
  const session = await this.findSession(token);
@@ -606,6 +736,40 @@ var Auth = class {
606
736
  for (const session of sessions.docs)
607
737
  await this.db.delete({ collection: SESSIONS, id: session.id });
608
738
  }
739
+ /** The owner of an API key, limited by the key; `null` when unknown, expired or disabled. */
740
+ async verifyApiKey(token) {
741
+ if (!this.cms.config.collections.some((c) => c.slug === API_KEYS)) return null;
742
+ const parsed = parseApiKey(token);
743
+ if (!parsed) return null;
744
+ const { docs } = await this.db.find({
745
+ collection: API_KEYS,
746
+ where: { prefix: { equals: parsed.prefix } },
747
+ sort: [],
748
+ limit: 1,
749
+ page: 1
750
+ });
751
+ const key2 = docs[0];
752
+ if (!key2 || !secretMatches(parsed.secret, key2.keyHash)) return null;
753
+ const now = /* @__PURE__ */ new Date();
754
+ if (typeof key2.expiresAt === "string" && key2.expiresAt <= now.toISOString()) return null;
755
+ const owner = await this.db.findById({ collection: USERS, id: key2.user });
756
+ if (!owner || owner.active === false) return null;
757
+ const last = typeof key2.lastUsedAt === "string" ? Date.parse(key2.lastUsedAt) : 0;
758
+ if (now.getTime() - last > 6e4) {
759
+ const { id: _id, ...rest } = key2;
760
+ await this.db.update({
761
+ collection: API_KEYS,
762
+ id: key2.id,
763
+ data: { ...rest, lastUsedAt: now.toISOString() }
764
+ });
765
+ }
766
+ const apiKey = {
767
+ id: key2.id,
768
+ name: String(key2.name ?? ""),
769
+ permissions: key2.permissions ?? {}
770
+ };
771
+ return { ...await this.toAuthUser(owner), apiKey };
772
+ }
609
773
  // -------------------------------------------------------------------------
610
774
  async startSession(user) {
611
775
  const token = newToken();
@@ -744,7 +908,8 @@ var ADMIN_ICONS = [
744
908
  "circle-help",
745
909
  "award",
746
910
  "ticket",
747
- "camera"
911
+ "camera",
912
+ "key"
748
913
  ];
749
914
  function defineConfig(config) {
750
915
  return config;
@@ -1285,6 +1450,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
1285
1450
  "document-versions",
1286
1451
  "scheduled-jobs",
1287
1452
  "webhook-deliveries",
1453
+ "api-keys",
1288
1454
  "jobs",
1289
1455
  "migrations",
1290
1456
  "access"
@@ -1333,6 +1499,8 @@ function validateConfig(config) {
1333
1499
  checkSlug(global2, `globals[${i}]`, seenGlobals, add);
1334
1500
  });
1335
1501
  validateEndpoints(config, collectionSlugs, add);
1502
+ const apiKeys = config.apiKeys;
1503
+ if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
1336
1504
  collections.forEach((collection2, i) => {
1337
1505
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
1338
1506
  validateContainer(collection2, path, collectionSlugs, add);
@@ -1804,7 +1972,7 @@ function validateComponents(components, path, add) {
1804
1972
  }
1805
1973
  var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
1806
1974
  var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
1807
- var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set(["users", "globals", "admin", "jobs", "media"]);
1975
+ var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set(["users", "globals", "admin", "jobs", "media", "api-keys"]);
1808
1976
  function validateEndpoints(config, collectionSlugs, add) {
1809
1977
  const endpoints = config.endpoints;
1810
1978
  if (endpoints === void 0) return;
@@ -1953,7 +2121,8 @@ async function resolveConfig(input) {
1953
2121
  ...internalCollections,
1954
2122
  ...usesVersions(config) ? [versionsCollection] : [],
1955
2123
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
1956
- ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : []
2124
+ ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
2125
+ ...config.apiKeys ? [apiKeysCollection] : []
1957
2126
  ],
1958
2127
  globals: config.globals ?? [],
1959
2128
  endpoints: config.endpoints ?? []
@@ -2014,7 +2183,7 @@ async function loadConfig(options = {}) {
2014
2183
  }
2015
2184
 
2016
2185
  // src/local-api.ts
2017
- import { randomBytes as randomBytes3 } from "crypto";
2186
+ import { randomBytes as randomBytes4 } from "crypto";
2018
2187
 
2019
2188
  // src/localization.ts
2020
2189
  var ALL_LOCALES = "all";
@@ -2593,6 +2762,7 @@ var EasyCMS = class {
2593
2762
  const config = this.collection(MEDIA);
2594
2763
  const guard = guardOf(options);
2595
2764
  if (guard.enforce) {
2765
+ this.checkKey(guard, { collection: MEDIA }, "create");
2596
2766
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
2597
2767
  if (allowed2 !== true) throw deny(guard.user);
2598
2768
  }
@@ -2611,7 +2781,7 @@ var EasyCMS = class {
2611
2781
  }
2612
2782
  ]);
2613
2783
  }
2614
- const random = randomBytes3(4).toString("hex");
2784
+ const random = randomBytes4(4).toString("hex");
2615
2785
  const filename = storageKey(file.name, mimeType, random);
2616
2786
  const stored = [];
2617
2787
  try {
@@ -2642,6 +2812,41 @@ var EasyCMS = class {
2642
2812
  throw error;
2643
2813
  }
2644
2814
  }
2815
+ /**
2816
+ * Creates an API key (needs `apiKeys: true`) and returns it once: only a hash is stored.
2817
+ * With access enforced the key belongs to `user`; trusted calls name the owner in `data.user`.
2818
+ */
2819
+ async createApiKey(data, options = {}) {
2820
+ if (!this.config.collections.some((c) => c.slug === API_KEYS))
2821
+ throw new QueryError("API keys are off: set `apiKeys: true` in the config");
2822
+ const config = this.collection(API_KEYS);
2823
+ const guard = guardOf(options);
2824
+ if (guard.enforce) {
2825
+ const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user });
2826
+ if (allowed2 !== true) throw deny(guard.user);
2827
+ }
2828
+ const owner = guard.enforce ? guard.user?.id : data.user;
2829
+ if (owner === void 0 || owner === null)
2830
+ throw new ValidationError(API_KEYS, [{ field: "user", message: "is required" }]);
2831
+ const { key: key2, prefix, hash } = newApiKey();
2832
+ const expiresAt = data.expiresAt instanceof Date ? data.expiresAt.toISOString() : data.expiresAt;
2833
+ const created = await this.createDocument(
2834
+ config,
2835
+ {
2836
+ name: data.name,
2837
+ permissions: data.permissions ?? {},
2838
+ expiresAt: expiresAt ?? null,
2839
+ prefix,
2840
+ user: owner
2841
+ },
2842
+ { depth: 0 },
2843
+ guard
2844
+ );
2845
+ const raw = await this.db.findById({ collection: API_KEYS, id: created.id });
2846
+ const { id: _id, ...rest } = raw;
2847
+ await this.db.update({ collection: API_KEYS, id: created.id, data: { ...rest, keyHash: hash } });
2848
+ return { key: key2, doc: created };
2849
+ }
2645
2850
  /** Public URL of a stored file. */
2646
2851
  mediaURL(key2) {
2647
2852
  const custom = this.storage.url?.(key2);
@@ -2739,6 +2944,7 @@ var EasyCMS = class {
2739
2944
  let parsed;
2740
2945
  if (id === null) {
2741
2946
  if (guard.enforce) {
2947
+ this.checkKey(guard, { collection: collection2 }, "create");
2742
2948
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
2743
2949
  if (allowed2 !== true) throw deny(guard.user);
2744
2950
  }
@@ -2813,7 +3019,7 @@ var EasyCMS = class {
2813
3019
  return { doc: global2, url: this.previewURL(config, global2, options) };
2814
3020
  }
2815
3021
  /** Coerces preview input like a save would, keeping values that would fail validation. */
2816
- async previewDoc(config, data, system, options = {}) {
3022
+ async previewDoc(config, data, system2, options = {}) {
2817
3023
  const locale = this.localeOf(options);
2818
3024
  const { data: clean } = await validateFields(config.fields, data, {
2819
3025
  operation: "update",
@@ -2826,7 +3032,7 @@ var EasyCMS = class {
2826
3032
  ...data,
2827
3033
  ...clean,
2828
3034
  ...config.drafts ? { status: data.status } : {},
2829
- ...system,
3035
+ ...system2,
2830
3036
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2831
3037
  };
2832
3038
  }
@@ -2857,6 +3063,8 @@ var EasyCMS = class {
2857
3063
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
2858
3064
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
2859
3065
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
3066
+ if (config.drafts && (mode === "unpublish" || raw.status === "published"))
3067
+ this.checkKey(guard, { collection: collection2 }, "publish");
2860
3068
  const [current] = await this.withDrafts(config, [existing]);
2861
3069
  const { input, password } = splitPassword(config, raw);
2862
3070
  const filtered = await filterInput(
@@ -3043,6 +3251,8 @@ var EasyCMS = class {
3043
3251
  const config = this.global(slug);
3044
3252
  const guard = guardOf(options);
3045
3253
  await this.checkGlobalAccess(config, "update", guard);
3254
+ if (config.drafts && (mode === "unpublish" || raw.status === "published"))
3255
+ this.checkKey(guard, { global: slug }, "publish");
3046
3256
  const input = await filterInput(
3047
3257
  config.fields,
3048
3258
  raw,
@@ -3276,12 +3486,14 @@ var EasyCMS = class {
3276
3486
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
3277
3487
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
3278
3488
  await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
3489
+ this.checkKey(guardOf(options), { collection: collection2 }, "publish");
3279
3490
  return { config, parsed };
3280
3491
  }
3281
3492
  async globalScheduleTarget(slug, options) {
3282
3493
  const config = this.global(slug);
3283
3494
  if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
3284
3495
  await this.checkGlobalAccess(config, "update", guardOf(options));
3496
+ this.checkKey(guardOf(options), { global: slug }, "publish");
3285
3497
  }
3286
3498
  async addJob(parent, doc, job, options) {
3287
3499
  const runAt = new Date(job.at);
@@ -3380,15 +3592,23 @@ var EasyCMS = class {
3380
3592
  if (!config) throw new QueryError(`Unknown global "${slug}"`);
3381
3593
  return config;
3382
3594
  }
3595
+ /** Refuses what a request's API key does not allow (see `ApiKeyPermissions`). */
3596
+ checkKey(guard, target, operation) {
3597
+ if (!guard.enforce || keyAllows(guard.user, target, operation)) return;
3598
+ const name = "collection" in target ? target.collection : target.global;
3599
+ throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
3600
+ }
3383
3601
  /** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
3384
3602
  async readWhere(config, guard, where) {
3385
3603
  if (!guard.enforce) return where;
3604
+ this.checkKey(guard, { collection: config.slug }, "read");
3386
3605
  const access = await evaluateAccess(config.access?.read, { user: guard.user });
3387
3606
  if (access === false) throw deny(guard.user);
3388
3607
  return andWhere(where, access);
3389
3608
  }
3390
3609
  async checkDocumentAccess(config, operation, guard, id, data) {
3391
3610
  if (!guard.enforce) return;
3611
+ this.checkKey(guard, { collection: config.slug }, operation);
3392
3612
  const access = await evaluateAccess(config.access?.[operation], {
3393
3613
  user: guard.user,
3394
3614
  id,
@@ -3406,6 +3626,7 @@ var EasyCMS = class {
3406
3626
  }
3407
3627
  async checkGlobalAccess(config, operation, guard) {
3408
3628
  if (!guard.enforce) return;
3629
+ this.checkKey(guard, { global: config.slug }, operation);
3409
3630
  const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
3410
3631
  if (typeof access === "object")
3411
3632
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
@@ -3459,6 +3680,9 @@ var EasyCMS = class {
3459
3680
  const guard = guardOf(options);
3460
3681
  const collection2 = config.slug;
3461
3682
  if (guard.enforce) {
3683
+ this.checkKey(guard, { collection: collection2 }, "create");
3684
+ if (config.drafts && raw.status === "published")
3685
+ this.checkKey(guard, { collection: collection2 }, "publish");
3462
3686
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
3463
3687
  if (typeof allowed2 === "object")
3464
3688
  throw new QueryError(`create access of "${collection2}" must return a boolean`);
@@ -3823,7 +4047,7 @@ function asObject(data, name) {
3823
4047
  }
3824
4048
 
3825
4049
  // src/rest/admin-modules.ts
3826
- import { createHash as createHash2 } from "crypto";
4050
+ import { createHash as createHash3 } from "crypto";
3827
4051
  import { readFile as readFile2 } from "fs/promises";
3828
4052
  import { createRequire } from "module";
3829
4053
  import { join as join2 } from "path";
@@ -3864,7 +4088,7 @@ async function readAdminModule(cms, index) {
3864
4088
  if (!file) return void 0;
3865
4089
  const body = await readFile2(file, "utf8").catch(() => void 0);
3866
4090
  if (body === void 0) return void 0;
3867
- return { body, etag: `"${createHash2("sha1").update(body).digest("base64url")}"` };
4091
+ return { body, etag: `"${createHash3("sha1").update(body).digest("base64url")}"` };
3868
4092
  }
3869
4093
 
3870
4094
  // src/rest/admin-schema.ts
@@ -4130,6 +4354,8 @@ function createRestHandler(instance, options = {}) {
4130
4354
  const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map((s) => decodeURIComponent(s));
4131
4355
  const { token, via } = readToken(request);
4132
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");
4133
4359
  const ctx = { request, url, user, via: user ? via : null, token, headers };
4134
4360
  const method = request.method.toUpperCase();
4135
4361
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
@@ -4331,6 +4557,18 @@ async function route(cms, ctx, method, segments, options) {
4331
4557
  body: await cms.upload(file, data, { ...access, ...parseDepth(ctx.url) })
4332
4558
  };
4333
4559
  }
4560
+ if (first === API_KEYS && second === void 0 && method === "POST") {
4561
+ const body = await readJson(ctx.request);
4562
+ const { key: key2, doc } = await cms.createApiKey(
4563
+ {
4564
+ name: typeof body.name === "string" ? body.name : "",
4565
+ ...body.permissions !== void 0 ? { permissions: body.permissions } : {},
4566
+ ...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
4567
+ },
4568
+ access
4569
+ );
4570
+ return { status: 201, body: { ...doc, key: key2 } };
4571
+ }
4334
4572
  const collection2 = first;
4335
4573
  if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
4336
4574
  throw new HttpError(`Unknown collection "${collection2}"`, 404);
@@ -4817,6 +5055,9 @@ ${map(n.globals)}
4817
5055
  }
4818
5056
  export {
4819
5057
  ADMIN_ICONS,
5058
+ API_KEYS,
5059
+ API_KEY_GLOBAL_OPERATIONS,
5060
+ API_KEY_OPERATIONS,
4820
5061
  Auth,
4821
5062
  BUILTIN_COLLECTIONS,
4822
5063
  CONFIG_FILE_NAMES,
@@ -4866,6 +5107,7 @@ export {
4866
5107
  importConfig,
4867
5108
  isAdmin,
4868
5109
  isLoggedIn,
5110
+ keyAllows,
4869
5111
  loadConfig,
4870
5112
  localStorage,
4871
5113
  mergeForUpdate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",