@easy-cms/core 0.33.0 → 0.34.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
@@ -744,6 +744,9 @@ type GlobalInput<C extends Config, S extends GlobalSlug<C>> = Partial<Simplify<F
744
744
  /** What a role may do with a collection or global, ticked in Settings → Roles. */
745
745
  declare const ROLE_OPERATIONS: readonly ["read", "create", "update", "delete", "publish"];
746
746
  type RoleOperation = (typeof ROLE_OPERATIONS)[number];
747
+ /** A field a role may only read, or not see at all; fields not listed follow the collection. */
748
+ type FieldRule = 'read' | 'hidden';
749
+ type FieldRules = Readonly<Record<string, Readonly<Record<string, FieldRule>>>>;
747
750
  /**
748
751
  * Stored in a role's `permissions`. Anything not listed is not allowed. A slug listed with no
749
752
  * operations has been looked at and given nothing (it is not "new" in Settings → Roles).
@@ -753,6 +756,13 @@ interface RolePermissions {
753
756
  readonly globals?: Readonly<Record<string, readonly RoleOperation[]>>;
754
757
  /** Admin pages: `status`, `deliveries`, `page:<path>` (`admin.pages`), `widget:<tag>` (`admin.dashboard`). */
755
758
  readonly admin?: readonly string[];
759
+ /** Operations of `collections` allowed only on the role's own documents (see `admin.ownerField`). */
760
+ readonly own?: Readonly<Record<string, readonly RoleOperation[]>>;
761
+ /** Top-level fields the role may only read or not see, by collection or global slug. */
762
+ readonly fields?: {
763
+ readonly collections?: FieldRules;
764
+ readonly globals?: FieldRules;
765
+ };
756
766
  }
757
767
  /** One role as Settings → Roles shows it (`GET <api>/admin/roles`). */
758
768
  interface AdminRole {
@@ -767,6 +777,15 @@ interface AdminRole {
767
777
  permissions: RolePermissions;
768
778
  updatedAt: string;
769
779
  }
780
+ /** A top-level field whose permission can be set per role. */
781
+ interface AdminRoleField {
782
+ name: string;
783
+ label?: Label;
784
+ /** Required without a default value: roles that create documents must be able to fill it. */
785
+ required: boolean;
786
+ /** The collection's owner field (`admin.ownerField`). */
787
+ owner?: boolean;
788
+ }
770
789
  /** A row of the permissions table. */
771
790
  interface AdminRoleTarget {
772
791
  slug: string;
@@ -774,6 +793,7 @@ interface AdminRoleTarget {
774
793
  ops: RoleOperation[];
775
794
  /** No role has been given anything here yet (added to the config since). */
776
795
  new: boolean;
796
+ fields: AdminRoleField[];
777
797
  }
778
798
  /** A page or dashboard panel that can be given to roles. */
779
799
  interface AdminRoleView {
@@ -787,6 +807,8 @@ interface AdminRoles {
787
807
  collections: (AdminRoleTarget & {
788
808
  /** Collections its relationship and upload fields point to. */
789
809
  references: string[];
810
+ /** Can be limited to own documents: the field that names the owner (`createdBy` by default). */
811
+ owner?: string;
790
812
  })[];
791
813
  globals: AdminRoleTarget[];
792
814
  views: AdminRoleView[];
@@ -808,11 +830,12 @@ interface AdminRoleChange {
808
830
  declare class Roles {
809
831
  private readonly config;
810
832
  private readonly db;
833
+ private readonly log?;
811
834
  readonly enabled: boolean;
812
835
  private readonly versions;
813
836
  private cache;
814
837
  private synced;
815
- constructor(config: ResolvedConfig, db: Database);
838
+ constructor(config: ResolvedConfig, db: Database, log?: ((message: string) => void) | undefined);
816
839
  /**
817
840
  * Whether the user's role allows an operation: `true`, `false`, or for their own account
818
841
  * (users without read or update on `users`) a constraint to it.
@@ -827,6 +850,25 @@ declare class Roles {
827
850
  * it; without roles, nobody else (`status`, `deliveries`) or everybody (plugin pages).
828
851
  */
829
852
  canView(user: AuthUser, id: string): Promise<boolean>;
853
+ /**
854
+ * The field that names a collection's owner, for "own documents only": `admin.ownerField`, or
855
+ * `createdBy`. `undefined` without roles, and for users and collections roles don't cover.
856
+ */
857
+ ownerOf(slug: string): string | undefined;
858
+ /**
859
+ * The user's field rules (top-level fields their role may only read, or not see), for field
860
+ * access checks. Roles that may update only their own documents can't change the owner field.
861
+ */
862
+ fieldRules(user: AuthUser | null): Promise<ReadonlyMap<Field, FieldRule> | undefined>;
863
+ /** A new document is its creator's: sets `createdBy`, and an empty owner field. */
864
+ fillOwner(slug: string, data: Record<string, unknown>, user: AuthUser | null): void;
865
+ /** Documents a user owns, by collection (`createdBy` and owner fields), for deleting them. */
866
+ owned(userId: ID): Promise<{
867
+ collection: string;
868
+ count: number;
869
+ }[]>;
870
+ /** Gives a deleted user's documents to another user, or to nobody (`null`). */
871
+ transfer(from: ID, to: ID | null): Promise<void>;
830
872
  /** Forgets cached permissions, after a role changed. */
831
873
  invalidate(): void;
832
874
  /**
@@ -835,6 +877,12 @@ declare class Roles {
835
877
  */
836
878
  sync(): Promise<void>;
837
879
  private addMissing;
880
+ /**
881
+ * Documents from before roles have no `createdBy`: it is filled from who saved their first
882
+ * version, for collections with versions. Only while no document of the collection has one,
883
+ * so it runs once (and finds nothing to do afterwards).
884
+ */
885
+ private backfillCreators;
838
886
  /** Whether a role exists (users can only be given existing roles). */
839
887
  exists(key: string): Promise<boolean>;
840
888
  /** Roles a user can be given, those of `auth.roles` first; `name` may be empty. */
@@ -860,6 +908,15 @@ declare class Roles {
860
908
  private everything;
861
909
  /** Keeps only known collections, globals, operations and pages; `read` comes with the others. */
862
910
  private clean;
911
+ /**
912
+ * Keeps rules for known top-level fields. A field that must be filled in (required, without a
913
+ * default) can't be read-only or hidden for a role that creates documents.
914
+ */
915
+ private cleanRules;
916
+ /** Fields that name a collection's owner (`createdBy`, and `admin.ownerField`). */
917
+ private ownerColumns;
918
+ /** Sets one field of every matching document, keeping the rest as stored. */
919
+ private setWhere;
863
920
  private cleanName;
864
921
  private permissionsOf;
865
922
  private load;
@@ -1107,7 +1164,13 @@ declare class EasyCMS<C extends Config = Config> {
1107
1164
  private previewURL;
1108
1165
  private versionTarget;
1109
1166
  private updateDocument;
1110
- delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions): Promise<Doc<C, S>>;
1167
+ /**
1168
+ * Deletes a document. Deleting a user with roles from the admin (`auth.rbac`): `transferTo`
1169
+ * gives the documents they own to another user; without it they have no owner.
1170
+ */
1171
+ delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions & {
1172
+ transferTo?: ID | null;
1173
+ }): Promise<Doc<C, S>>;
1111
1174
  findGlobal<S extends GSlug<C>>(slug: S, options?: ReadOptions): Promise<GDoc<C, S>>;
1112
1175
  updateGlobal<S extends GSlug<C>>(slug: S, data: GInput<C, S>, options?: DepthOptions): Promise<GDoc<C, S>>;
1113
1176
  /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
@@ -1244,6 +1307,13 @@ declare class EasyCMS<C extends Config = Config> {
1244
1307
  private checkDocumentAccess;
1245
1308
  private checkGlobalAccess;
1246
1309
  private fieldChecker;
1310
+ /**
1311
+ * Refuses filtering or sorting by fields the caller may not read: the results would tell
1312
+ * their values. Checks each field along a dotted path, through groups, arrays and blocks.
1313
+ */
1314
+ private checkQueryFields;
1315
+ /** `checkGrant` for one document: a role limited to its own documents must own it. */
1316
+ private checkDocumentGrant;
1247
1317
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
1248
1318
  private output;
1249
1319
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
@@ -1638,6 +1708,11 @@ interface CollectionAdmin extends ContainerAdmin {
1638
1708
  */
1639
1709
  readonly group?: 'settings';
1640
1710
  readonly list?: CollectionListAdmin;
1711
+ /**
1712
+ * With `auth.rbac`: the relationship field to `users` that names a document's owner, e.g.
1713
+ * `author`, for roles given "own documents only". Default: who created it (`createdBy`).
1714
+ */
1715
+ readonly ownerField?: string;
1641
1716
  }
1642
1717
  interface CollectionListAdmin {
1643
1718
  /**
@@ -2427,6 +2502,8 @@ interface AdminCollection {
2427
2502
  delete: boolean;
2428
2503
  publish: boolean;
2429
2504
  };
2505
+ /** With `auth.rbac`: the field naming a document's owner (`createdBy` or `admin.ownerField`). */
2506
+ owner?: string;
2430
2507
  }
2431
2508
  interface AdminGlobal {
2432
2509
  slug: string;
@@ -2491,6 +2568,8 @@ interface AdminSchema {
2491
2568
  email: boolean;
2492
2569
  roles: boolean;
2493
2570
  };
2571
+ /** Roles from the admin are on (`auth.rbac`): documents have owners. */
2572
+ rbac: boolean;
2494
2573
  /** Pages this user may open (`admin.pages`). */
2495
2574
  pages: AdminPageRef[];
2496
2575
  /** Dashboard panels this user may see (`admin.dashboard`). */
@@ -2589,4 +2668,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
2589
2668
  /** The version of `@easy-cms/core`, set when the package is built. */
2590
2669
  declare const VERSION: string;
2591
2670
 
2592
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, 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 CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, 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, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword, writeBackupFile };
2671
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, 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 CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, 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, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword, writeBackupFile };
package/dist/index.js CHANGED
@@ -253,14 +253,19 @@ function andWhere(where, constraint) {
253
253
  return where === void 0 ? constraint : { and: [where, constraint] };
254
254
  }
255
255
  var FieldAccessChecker = class {
256
- constructor(kind, args) {
256
+ /** `rules`: the user's role's rules for top-level fields (`auth.rbac`), on top of `access`. */
257
+ constructor(kind, args, rules) {
257
258
  this.kind = kind;
258
259
  this.args = args;
260
+ this.rules = rules;
259
261
  }
260
262
  kind;
261
263
  args;
264
+ rules;
262
265
  cache = /* @__PURE__ */ new Map();
263
266
  async allows(field) {
267
+ const rule = this.rules?.get(field);
268
+ if (rule === "hidden" || rule === "read" && this.kind === "update") return false;
264
269
  const fn = field.access?.[this.kind];
265
270
  if (!fn) return true;
266
271
  let allowed2 = this.cache.get(field);
@@ -338,6 +343,23 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
338
343
  ROLES
339
344
  ]);
340
345
  var DEFAULT_ROLES = ["admin", "editor"];
346
+ var CREATED_BY_FIELD = {
347
+ name: "createdBy",
348
+ type: "relationship",
349
+ to: USERS,
350
+ position: "sidebar",
351
+ access: { update: () => false },
352
+ label: { en: "Created by", th: "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E42\u0E14\u0E22" }
353
+ };
354
+ function withCreatedBy(config) {
355
+ if (config.auth?.rbac !== true) return config;
356
+ return {
357
+ ...config,
358
+ collections: (config.collections ?? []).map(
359
+ (c) => c.slug === USERS || c.fields?.some((f) => f.name === CREATED_BY_FIELD.name) ? c : { ...c, fields: [...c.fields, CREATED_BY_FIELD] }
360
+ )
361
+ };
362
+ }
341
363
  var adminOrSelf = ({ user }) => {
342
364
  if (!user) return false;
343
365
  if (user.role === "admin") return true;
@@ -1361,6 +1383,7 @@ async function runBackup(cms, record) {
1361
1383
  const filename = filenameFor(cms, started);
1362
1384
  const key2 = await storage.get(filename) ? filename.replace(/\.db\.gz$/, `-${row.id}.db.gz`) : filename;
1363
1385
  await storage.put(key2, data, { contentType: "application/gzip" });
1386
+ await pruneBackups(cms, 1);
1364
1387
  row = await update(cms, row, {
1365
1388
  state: "done",
1366
1389
  filename: key2,
@@ -1368,7 +1391,6 @@ async function runBackup(cms, record) {
1368
1391
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1369
1392
  });
1370
1393
  cms.logger.info(`Backup ${key2} written (${data.byteLength} bytes)`);
1371
- await pruneBackups(cms);
1372
1394
  } catch (error) {
1373
1395
  await update(cms, row, {
1374
1396
  state: "failed",
@@ -1380,8 +1402,8 @@ async function runBackup(cms, record) {
1380
1402
  await rm2(dir, { recursive: true, force: true });
1381
1403
  }
1382
1404
  }
1383
- async function pruneBackups(cms) {
1384
- const keep = cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP;
1405
+ async function pruneBackups(cms, room = 0) {
1406
+ const keep = Math.max(0, (cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP) - room);
1385
1407
  const storage = await backupStorage(cms);
1386
1408
  const { docs } = await cms.db.find({
1387
1409
  collection: DATABASE_BACKUPS,
@@ -2313,6 +2335,7 @@ var VersionStore = class {
2313
2335
  // src/roles.ts
2314
2336
  var ROLE_OPERATIONS = ["read", "create", "update", "delete", "publish"];
2315
2337
  var GLOBAL_OPERATIONS = ["read", "update", "publish"];
2338
+ var OWN_OPERATIONS = ["read", "update", "delete", "publish"];
2316
2339
  var KEY = /^[a-z][a-z0-9-]{0,31}$/;
2317
2340
  var HISTORY = 50;
2318
2341
  var TTL = 3e4;
@@ -2328,6 +2351,9 @@ var rolesCollection = {
2328
2351
  function governed(slug2) {
2329
2352
  return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
2330
2353
  }
2354
+ function ruleFields(fields) {
2355
+ return fields.filter((f) => !f.hidden && f.name !== "createdBy");
2356
+ }
2331
2357
  function references(fields, out = /* @__PURE__ */ new Set()) {
2332
2358
  for (const field of fields) {
2333
2359
  if (field.type === "relationship") out.add(field.to);
@@ -2343,14 +2369,16 @@ var viewId = {
2343
2369
  };
2344
2370
  var tagOf = (component) => typeof component === "string" ? component : component.tag;
2345
2371
  var Roles = class {
2346
- constructor(config, db) {
2372
+ constructor(config, db, log) {
2347
2373
  this.config = config;
2348
2374
  this.db = db;
2375
+ this.log = log;
2349
2376
  this.enabled = config.auth.rbac;
2350
2377
  this.versions = new VersionStore(db);
2351
2378
  }
2352
2379
  config;
2353
2380
  db;
2381
+ log;
2354
2382
  enabled;
2355
2383
  versions;
2356
2384
  cache;
@@ -2364,7 +2392,13 @@ var Roles = class {
2364
2392
  if ("collection" in target && !governed(target.collection)) return true;
2365
2393
  const permissions = await this.permissionsOf(user.role);
2366
2394
  const ops = "collection" in target ? permissions.collections?.[target.collection] : permissions.globals?.[target.global];
2367
- if (ops?.includes(operation)) return true;
2395
+ if (ops?.includes(operation)) {
2396
+ if ("collection" in target && permissions.own?.[target.collection]?.includes(operation)) {
2397
+ const owner = this.ownerOf(target.collection);
2398
+ if (owner && operation !== "create") return { [owner]: { equals: user.id } };
2399
+ }
2400
+ return true;
2401
+ }
2368
2402
  if ("collection" in target && target.collection === USERS) {
2369
2403
  if (operation === "read" || operation === "update") return { id: { equals: user.id } };
2370
2404
  }
@@ -2380,6 +2414,76 @@ var Roles = class {
2380
2414
  if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
2381
2415
  return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
2382
2416
  }
2417
+ /**
2418
+ * The field that names a collection's owner, for "own documents only": `admin.ownerField`, or
2419
+ * `createdBy`. `undefined` without roles, and for users and collections roles don't cover.
2420
+ */
2421
+ ownerOf(slug2) {
2422
+ if (!this.enabled || slug2 === USERS || !governed(slug2)) return void 0;
2423
+ const config = this.config.collections.find((c) => c.slug === slug2);
2424
+ if (!config) return void 0;
2425
+ return config.admin?.ownerField ?? "createdBy";
2426
+ }
2427
+ /**
2428
+ * The user's field rules (top-level fields their role may only read, or not see), for field
2429
+ * access checks. Roles that may update only their own documents can't change the owner field.
2430
+ */
2431
+ async fieldRules(user) {
2432
+ if (!this.enabled || !user || user.role === "admin") return void 0;
2433
+ await this.load();
2434
+ const cache = this.cache;
2435
+ const cached = cache?.rules.get(user.role);
2436
+ if (cached) return cached;
2437
+ const permissions = await this.permissionsOf(user.role);
2438
+ const rules = /* @__PURE__ */ new Map();
2439
+ const add = (fields, set) => {
2440
+ for (const field of fields) {
2441
+ const rule = set?.[field.name];
2442
+ if (rule === "read" || rule === "hidden") rules.set(field, rule);
2443
+ }
2444
+ };
2445
+ for (const c of this.config.collections) {
2446
+ if (!governed(c.slug)) continue;
2447
+ add(c.fields, permissions.fields?.collections?.[c.slug]);
2448
+ const ownerField = c.admin?.ownerField;
2449
+ if (ownerField && permissions.own?.[c.slug]?.includes("update")) {
2450
+ const field = c.fields.find((f) => f.name === ownerField);
2451
+ if (field && !rules.has(field)) rules.set(field, "read");
2452
+ }
2453
+ }
2454
+ for (const g of this.config.globals) add(g.fields, permissions.fields?.globals?.[g.slug]);
2455
+ cache?.rules.set(user.role, rules);
2456
+ return rules;
2457
+ }
2458
+ /** A new document is its creator's: sets `createdBy`, and an empty owner field. */
2459
+ fillOwner(slug2, data, user) {
2460
+ if (!user || !this.ownerOf(slug2)) return;
2461
+ data.createdBy ??= user.id;
2462
+ const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
2463
+ if (ownerField && (data[ownerField] === void 0 || data[ownerField] === null))
2464
+ data[ownerField] = user.id;
2465
+ }
2466
+ /** Documents a user owns, by collection (`createdBy` and owner fields), for deleting them. */
2467
+ async owned(userId) {
2468
+ const out = [];
2469
+ for (const c of this.config.collections) {
2470
+ const columns = this.ownerColumns(c.slug);
2471
+ if (columns.length === 0) continue;
2472
+ const count = await this.db.count({
2473
+ collection: c.slug,
2474
+ where: { or: columns.map((column) => ({ [column]: { equals: userId } })) }
2475
+ });
2476
+ if (count > 0) out.push({ collection: c.slug, count });
2477
+ }
2478
+ return out;
2479
+ }
2480
+ /** Gives a deleted user's documents to another user, or to nobody (`null`). */
2481
+ async transfer(from, to) {
2482
+ for (const c of this.config.collections) {
2483
+ for (const column of this.ownerColumns(c.slug))
2484
+ await this.setWhere(c.slug, { [column]: { equals: from } }, column, to);
2485
+ }
2486
+ }
2383
2487
  /** Forgets cached permissions, after a role changed. */
2384
2488
  invalidate() {
2385
2489
  this.cache = void 0;
@@ -2409,6 +2513,39 @@ var Roles = class {
2409
2513
  }
2410
2514
  }
2411
2515
  this.invalidate();
2516
+ await this.backfillCreators();
2517
+ }
2518
+ /**
2519
+ * Documents from before roles have no `createdBy`: it is filled from who saved their first
2520
+ * version, for collections with versions. Only while no document of the collection has one,
2521
+ * so it runs once (and finds nothing to do afterwards).
2522
+ */
2523
+ async backfillCreators() {
2524
+ for (const c of this.config.collections) {
2525
+ if (!c.versions || !this.ownerOf(c.slug)) continue;
2526
+ if (await this.db.count({ collection: c.slug, where: { createdBy: { exists: true } } }) > 0)
2527
+ continue;
2528
+ const versions = await this.db.find({
2529
+ collection: VERSIONS,
2530
+ where: { and: [{ parent: { equals: c.slug } }, { author: { exists: true } }] },
2531
+ sort: ["id"],
2532
+ limit: 0,
2533
+ page: 1
2534
+ });
2535
+ const first = /* @__PURE__ */ new Map();
2536
+ for (const v of versions.docs) {
2537
+ if (!first.has(v.doc) && v.author !== null) first.set(v.doc, v.author);
2538
+ }
2539
+ let filled = 0;
2540
+ for (const [doc, author] of first) {
2541
+ const row = await this.db.findById({ collection: c.slug, id: doc });
2542
+ if (!row || row.createdBy !== null && row.createdBy !== void 0) continue;
2543
+ const { id, ...rest } = row;
2544
+ await this.db.update({ collection: c.slug, id, data: { ...rest, createdBy: author } });
2545
+ filled++;
2546
+ }
2547
+ if (filled > 0) this.log?.(`Set who created ${filled} ${c.slug} from their history`);
2548
+ }
2412
2549
  }
2413
2550
  /** Whether a role exists (users can only be given existing roles). */
2414
2551
  async exists(key2) {
@@ -2437,16 +2574,24 @@ var Roles = class {
2437
2574
  const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
2438
2575
  return {
2439
2576
  roles,
2440
- collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => ({
2441
- slug: c.slug,
2442
- ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
2443
- new: !seen("collections", c.slug),
2444
- references: [...references(c.fields)].filter((s) => s !== c.slug && governed(s))
2445
- })),
2577
+ collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => {
2578
+ const owner = this.ownerOf(c.slug);
2579
+ return {
2580
+ slug: c.slug,
2581
+ ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
2582
+ new: !seen("collections", c.slug),
2583
+ references: [...references(ruleFields(c.fields))].filter(
2584
+ (s) => s !== c.slug && governed(s)
2585
+ ),
2586
+ fields: adminFields(c.fields, c.admin?.ownerField),
2587
+ ...owner ? { owner } : {}
2588
+ };
2589
+ }),
2446
2590
  globals: this.config.globals.map((g) => ({
2447
2591
  slug: g.slug,
2448
2592
  ops: GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true),
2449
- new: !seen("globals", g.slug)
2593
+ new: !seen("globals", g.slug),
2594
+ fields: adminFields(g.fields, void 0)
2450
2595
  })),
2451
2596
  views: this.views()
2452
2597
  };
@@ -2560,9 +2705,9 @@ var Roles = class {
2560
2705
  for (const [slug2, ops] of Object.entries(group)) {
2561
2706
  const valid = allowed2.get(slug2);
2562
2707
  if (!valid || !Array.isArray(ops)) continue;
2563
- const picked = valid.filter((op) => ops.includes(op));
2564
- if (picked.length > 0 && !picked.includes("read")) picked.unshift("read");
2565
- out[slug2] = picked;
2708
+ const picked2 = valid.filter((op) => ops.includes(op));
2709
+ if (picked2.length > 0 && !picked2.includes("read")) picked2.unshift("read");
2710
+ out[slug2] = picked2;
2566
2711
  }
2567
2712
  return out;
2568
2713
  };
@@ -2584,11 +2729,71 @@ var Roles = class {
2584
2729
  input.admin.filter((id) => typeof id === "string" && views.has(id))
2585
2730
  )
2586
2731
  ] : [];
2587
- return {
2732
+ const picked = {
2588
2733
  collections: pick(input.collections, collections),
2589
- globals: pick(input.globals, globals),
2590
- admin
2734
+ globals: pick(input.globals, globals)
2591
2735
  };
2736
+ const own = {};
2737
+ if (typeof input.own === "object" && input.own !== null) {
2738
+ for (const [slug2, ops] of Object.entries(input.own)) {
2739
+ const granted = picked.collections[slug2];
2740
+ if (!granted || !this.ownerOf(slug2) || !Array.isArray(ops)) continue;
2741
+ const valid = OWN_OPERATIONS.filter((op) => granted.includes(op) && ops.includes(op));
2742
+ if (valid.length > 0) own[slug2] = valid;
2743
+ }
2744
+ }
2745
+ const fields = {
2746
+ collections: this.cleanRules(
2747
+ input.fields?.collections,
2748
+ this.config.collections.filter((c) => governed(c.slug)),
2749
+ picked.collections
2750
+ ),
2751
+ globals: this.cleanRules(
2752
+ input.fields?.globals,
2753
+ this.config.globals,
2754
+ void 0
2755
+ )
2756
+ };
2757
+ return { ...picked, admin, own, fields };
2758
+ }
2759
+ /**
2760
+ * Keeps rules for known top-level fields. A field that must be filled in (required, without a
2761
+ * default) can't be read-only or hidden for a role that creates documents.
2762
+ */
2763
+ cleanRules(value, containers, granted) {
2764
+ const out = {};
2765
+ if (typeof value !== "object" || value === null) return out;
2766
+ for (const [slug2, rules] of Object.entries(value)) {
2767
+ const container = containers.find((c) => c.slug === slug2);
2768
+ if (!container || typeof rules !== "object" || rules === null) continue;
2769
+ const set = {};
2770
+ for (const field of ruleFields(container.fields)) {
2771
+ const rule = rules[field.name];
2772
+ if (rule !== "read" && rule !== "hidden") continue;
2773
+ if (granted?.[slug2]?.includes("create") && mustFill(field))
2774
+ throw new ValidationError(ROLES, [
2775
+ {
2776
+ field: `fields.${slug2}.${field.name}`,
2777
+ message: "is required: roles that create documents must be able to fill it"
2778
+ }
2779
+ ]);
2780
+ set[field.name] = rule;
2781
+ }
2782
+ if (Object.keys(set).length > 0) out[slug2] = set;
2783
+ }
2784
+ return out;
2785
+ }
2786
+ /** Fields that name a collection's owner (`createdBy`, and `admin.ownerField`). */
2787
+ ownerColumns(slug2) {
2788
+ if (!this.ownerOf(slug2)) return [];
2789
+ const ownerField = this.config.collections.find((c) => c.slug === slug2)?.admin?.ownerField;
2790
+ return ownerField ? ["createdBy", ownerField] : ["createdBy"];
2791
+ }
2792
+ /** Sets one field of every matching document, keeping the rest as stored. */
2793
+ async setWhere(collection2, where, field, value) {
2794
+ const { docs } = await this.db.find({ collection: collection2, where, sort: ["id"], limit: 0, page: 1 });
2795
+ for (const { id, ...rest } of docs)
2796
+ await this.db.update({ collection: collection2, id, data: { ...rest, [field]: value } });
2592
2797
  }
2593
2798
  cleanName(value) {
2594
2799
  if (value === void 0 || value === null) return "";
@@ -2607,7 +2812,7 @@ var Roles = class {
2607
2812
  const byKey = /* @__PURE__ */ new Map();
2608
2813
  for (const row of await this.rows())
2609
2814
  byKey.set(String(row.key), row.permissions ?? {});
2610
- this.cache = { at: Date.now(), byKey };
2815
+ this.cache = { at: Date.now(), byKey, rules: /* @__PURE__ */ new Map() };
2611
2816
  return byKey;
2612
2817
  }
2613
2818
  async rows() {
@@ -2655,6 +2860,17 @@ var Roles = class {
2655
2860
  };
2656
2861
  }
2657
2862
  };
2863
+ function mustFill(field) {
2864
+ return field.required === true && field.defaultValue === void 0;
2865
+ }
2866
+ function adminFields(fields, ownerField) {
2867
+ return ruleFields(fields).map((f) => ({
2868
+ name: f.name,
2869
+ ...f.label !== void 0 ? { label: f.label } : {},
2870
+ required: mustFill(f),
2871
+ ...f.name === ownerField ? { owner: true } : {}
2872
+ }));
2873
+ }
2658
2874
  function rank(role, config) {
2659
2875
  const index = config.auth.roles.indexOf(role.key);
2660
2876
  return index === -1 ? config.auth.roles.length : index;
@@ -2908,7 +3124,10 @@ function validateConfig(config) {
2908
3124
  if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
2909
3125
  collections.forEach((collection2, i) => {
2910
3126
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
2911
- validateContainer(collection2, path, collectionSlugs, add);
3127
+ validateContainer(collection2, path, collectionSlugs, add, {
3128
+ collection: true,
3129
+ rbac: config.auth?.rbac === true
3130
+ });
2912
3131
  validateUseAsTitle(collection2, path, add);
2913
3132
  validateIcon(collection2, path, add);
2914
3133
  const editIn = collection2.editIn;
@@ -2917,7 +3136,10 @@ function validateConfig(config) {
2917
3136
  });
2918
3137
  globals.forEach((global2, i) => {
2919
3138
  const path = `globals.${global2.slug ?? `[${i}]`}`;
2920
- validateContainer(global2, path, collectionSlugs, add);
3139
+ validateContainer(global2, path, collectionSlugs, add, {
3140
+ collection: false,
3141
+ rbac: config.auth?.rbac === true
3142
+ });
2921
3143
  validateIcon(global2, path, add);
2922
3144
  });
2923
3145
  const plugins = config.plugins;
@@ -3361,7 +3583,7 @@ function checkSlug(container, path, seen, add) {
3361
3583
  seen.add(slug2);
3362
3584
  return slug2;
3363
3585
  }
3364
- function validateContainer(container, path, collectionSlugs, add) {
3586
+ function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac }) {
3365
3587
  if (!Array.isArray(container.fields)) {
3366
3588
  add(`${path}.fields`, "must be an array");
3367
3589
  return;
@@ -3398,8 +3620,25 @@ function validateContainer(container, path, collectionSlugs, add) {
3398
3620
  add(`${path}.admin.group`, `must be 'settings' (got ${JSON.stringify(group)})`);
3399
3621
  const list = admin.list;
3400
3622
  if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
3623
+ const owner = admin.ownerField;
3624
+ if (owner !== void 0) {
3625
+ const field = container.fields.find((f) => f.name === owner);
3626
+ if (!collection2 || container.slug === "users" || field?.type !== "relationship" || field.to !== "users" || field.hasMany)
3627
+ add(
3628
+ `${path}.admin.ownerField`,
3629
+ 'must name a relationship field to "users" (not hasMany) of a collection',
3630
+ "e.g. { name: 'author', type: 'relationship', to: 'users' }"
3631
+ );
3632
+ }
3401
3633
  }
3402
3634
  }
3635
+ const createdBy = container.fields.find((f) => f.name === CREATED_BY_FIELD.name);
3636
+ if (rbac && createdBy && createdBy !== CREATED_BY_FIELD)
3637
+ add(
3638
+ `${path}.fields.createdBy`,
3639
+ '"createdBy" is added by Easy CMS with auth.rbac',
3640
+ "rename the field; to use it as the owner, set admin.ownerField"
3641
+ );
3403
3642
  const reserved = new Set(SYSTEM_FIELD_NAMES);
3404
3643
  if (container.drafts) reserved.add("status");
3405
3644
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -3753,7 +3992,7 @@ async function resolveConfig(input) {
3753
3992
  if (resolved.has(input)) return input;
3754
3993
  let config = await applyPlugins(input);
3755
3994
  if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
3756
- config = withMedia(withUsers(config));
3995
+ config = withCreatedBy(withMedia(withUsers(config)));
3757
3996
  }
3758
3997
  const applied = applyFieldTypes(config);
3759
3998
  config = applied.config;
@@ -4402,7 +4641,7 @@ var EasyCMS = class {
4402
4641
  this.storage = storage;
4403
4642
  this.cwd = cwd;
4404
4643
  this.versions = new VersionStore(db);
4405
- this.roles = new Roles(config, db);
4644
+ this.roles = new Roles(config, db, (message2) => logger.info(message2));
4406
4645
  this.webhooks = new Webhooks(
4407
4646
  config.webhooks ?? [],
4408
4647
  logger,
@@ -4429,6 +4668,7 @@ var EasyCMS = class {
4429
4668
  options
4430
4669
  );
4431
4670
  const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
4671
+ await this.checkQueryFields(config, guard, void 0, requested);
4432
4672
  const locale = this.localeOf(options);
4433
4673
  const sort = locale === void 0 ? requested : localizeSort(
4434
4674
  config.fields,
@@ -4723,7 +4963,7 @@ var EasyCMS = class {
4723
4963
  const filtered = await filterInput(
4724
4964
  config.fields,
4725
4965
  input,
4726
- this.fieldChecker("update", guard, parsed, input)
4966
+ await this.fieldChecker("update", guard, parsed, input)
4727
4967
  );
4728
4968
  const localized = this.toMaps(config, filtered, base ?? {}, options);
4729
4969
  const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
@@ -4763,7 +5003,7 @@ var EasyCMS = class {
4763
5003
  const input = await filterInput(
4764
5004
  config.fields,
4765
5005
  raw,
4766
- this.fieldChecker("update", guard, void 0, raw)
5006
+ await this.fieldChecker("update", guard, void 0, raw)
4767
5007
  );
4768
5008
  const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
4769
5009
  const current = await this.globalDraft(config, saved);
@@ -4828,7 +5068,7 @@ var EasyCMS = class {
4828
5068
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4829
5069
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
4830
5070
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4831
- await this.checkGrant(guard, { collection: collection2 }, "publish");
5071
+ await this.checkDocumentGrant(guard, collection2, "publish", parsed);
4832
5072
  const [drafted] = await this.withDrafts(config, [existing]);
4833
5073
  const live = options.live === true && mode === "save";
4834
5074
  const current = live ? existing : drafted;
@@ -4840,7 +5080,7 @@ var EasyCMS = class {
4840
5080
  const filtered = await filterInput(
4841
5081
  config.fields,
4842
5082
  input,
4843
- this.fieldChecker("update", guard, parsed, input)
5083
+ await this.fieldChecker("update", guard, parsed, input)
4844
5084
  );
4845
5085
  const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
4846
5086
  let merged = mergeForUpdate(config.fields, current, localized);
@@ -4908,6 +5148,10 @@ var EasyCMS = class {
4908
5148
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
4909
5149
  return out;
4910
5150
  }
5151
+ /**
5152
+ * Deletes a document. Deleting a user with roles from the admin (`auth.rbac`): `transferTo`
5153
+ * gives the documents they own to another user; without it they have no owner.
5154
+ */
4911
5155
  async delete(collection2, id, options = {}) {
4912
5156
  const config = this.collection(collection2);
4913
5157
  const guard = guardOf(options);
@@ -4919,7 +5163,19 @@ var EasyCMS = class {
4919
5163
  await this.guardLastAdmin(parsed, existing, { ...existing, active: false });
4920
5164
  const base = this.hookArgs(config, guard);
4921
5165
  for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
4922
- if (config.slug === USERS) await this.auth.revokeSessions(parsed);
5166
+ if (config.slug === USERS) {
5167
+ const to = options.transferTo;
5168
+ if (to !== void 0 && to !== null) {
5169
+ const target = parseId(to);
5170
+ const heir = target === void 0 ? null : await this.db.findById({ collection: USERS, id: target });
5171
+ if (!heir || String(heir.id) === String(parsed))
5172
+ throw new ValidationError(USERS, [
5173
+ { field: "transferTo", message: "must be another existing user" }
5174
+ ]);
5175
+ await this.roles.transfer(parsed, heir.id);
5176
+ } else await this.roles.transfer(parsed, null);
5177
+ await this.auth.revokeSessions(parsed);
5178
+ }
4923
5179
  await this.db.delete({ collection: collection2, id: parsed });
4924
5180
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
4925
5181
  if (config.schedule) {
@@ -5027,7 +5283,7 @@ var EasyCMS = class {
5027
5283
  const input = await filterInput(
5028
5284
  config.fields,
5029
5285
  raw,
5030
- this.fieldChecker("update", guard, void 0, raw)
5286
+ await this.fieldChecker("update", guard, void 0, raw)
5031
5287
  );
5032
5288
  const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
5033
5289
  const current = await this.globalDraft(config, existing);
@@ -5312,7 +5568,7 @@ var EasyCMS = class {
5312
5568
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
5313
5569
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
5314
5570
  await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
5315
- await this.checkGrant(guardOf(options), { collection: collection2 }, "publish");
5571
+ await this.checkDocumentGrant(guardOf(options), collection2, "publish", parsed);
5316
5572
  return { config, parsed };
5317
5573
  }
5318
5574
  async globalScheduleTarget(slug2, options) {
@@ -5483,6 +5739,7 @@ var EasyCMS = class {
5483
5739
  const own = await this.checkGrant(guard, { collection: config.slug }, "read");
5484
5740
  const access = await evaluateAccess(config.access?.read, { user: guard.user });
5485
5741
  if (access === false) throw deny(guard.user);
5742
+ await this.checkQueryFields(config, guard, where);
5486
5743
  return andWhere(andWhere(where, access), own);
5487
5744
  }
5488
5745
  async checkDocumentAccess(config, operation, guard, id, data) {
@@ -5512,17 +5769,52 @@ var EasyCMS = class {
5512
5769
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
5513
5770
  if (!access) throw deny(guard.user);
5514
5771
  }
5515
- fieldChecker(kind, guard, id, data) {
5772
+ async fieldChecker(kind, guard, id, data) {
5516
5773
  if (!guard.enforce) return void 0;
5517
- return new FieldAccessChecker(kind, {
5518
- user: guard.user,
5519
- ...id !== void 0 ? { id } : {},
5520
- ...data ? { data } : {}
5774
+ return new FieldAccessChecker(
5775
+ kind,
5776
+ {
5777
+ user: guard.user,
5778
+ ...id !== void 0 ? { id } : {},
5779
+ ...data ? { data } : {}
5780
+ },
5781
+ await this.roles.fieldRules(guard.user)
5782
+ );
5783
+ }
5784
+ /**
5785
+ * Refuses filtering or sorting by fields the caller may not read: the results would tell
5786
+ * their values. Checks each field along a dotted path, through groups, arrays and blocks.
5787
+ */
5788
+ async checkQueryFields(config, guard, where, sort = []) {
5789
+ if (!guard.enforce) return;
5790
+ const paths = [...wherePaths(where), ...sort.map((s) => s.replace(/^-/, ""))];
5791
+ if (paths.length === 0) return;
5792
+ const read2 = await this.fieldChecker("read", guard, void 0, void 0);
5793
+ for (const path of paths) {
5794
+ let fields = config.fields;
5795
+ for (const name of path.split(".")) {
5796
+ if (!fields) break;
5797
+ const field = fields.find((f) => f.name === name);
5798
+ if (!field) break;
5799
+ if (field.hidden || read2 && !await read2.allows(field))
5800
+ throw new ForbiddenError(`You may not filter or sort by "${path}"`);
5801
+ fields = field.type === "group" || field.type === "array" ? field.fields : field.type === "blocks" ? field.blocks.flatMap((b) => b.fields) : void 0;
5802
+ }
5803
+ }
5804
+ }
5805
+ /** `checkGrant` for one document: a role limited to its own documents must own it. */
5806
+ async checkDocumentGrant(guard, collection2, operation, id) {
5807
+ const own = await this.checkGrant(guard, { collection: collection2 }, operation);
5808
+ if (own === true) return;
5809
+ const matches2 = await this.db.count({
5810
+ collection: collection2,
5811
+ where: andWhere(own, { id: { equals: id } })
5521
5812
  });
5813
+ if (matches2 === 0) throw deny(guard.user);
5522
5814
  }
5523
5815
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
5524
5816
  async output(config, docs, guard, options) {
5525
- const read2 = this.fieldChecker("read", guard, void 0, void 0);
5817
+ const read2 = await this.fieldChecker("read", guard, void 0, void 0);
5526
5818
  const finish = async (target, doc) => {
5527
5819
  const hooked = await this.transform(
5528
5820
  target.hooks?.afterRead,
@@ -5572,8 +5864,9 @@ var EasyCMS = class {
5572
5864
  const filtered = await filterInput(
5573
5865
  config.fields,
5574
5866
  input,
5575
- this.fieldChecker("update", guard, void 0, input)
5867
+ await this.fieldChecker("update", guard, void 0, input)
5576
5868
  );
5869
+ this.roles.fillOwner(collection2, filtered, guard.user ?? hookGuard?.user ?? null);
5577
5870
  const base = this.hookArgs(config, hookGuard ?? guard);
5578
5871
  let data = applyDefaults(
5579
5872
  config.fields,
@@ -5944,6 +6237,16 @@ function loadSharp() {
5944
6237
  );
5945
6238
  return sharpModule;
5946
6239
  }
6240
+ function wherePaths(where) {
6241
+ if (!where) return [];
6242
+ const out = [];
6243
+ for (const [key2, value] of Object.entries(where)) {
6244
+ if (key2 === "and" || key2 === "or") {
6245
+ for (const sub of value ?? []) out.push(...wherePaths(sub));
6246
+ } else out.push(key2);
6247
+ }
6248
+ return out;
6249
+ }
5947
6250
  function guardOf(options) {
5948
6251
  return { enforce: options.overrideAccess === false, user: options.user ?? null };
5949
6252
  }
@@ -6215,10 +6518,11 @@ function componentRef(component) {
6215
6518
  async function allowed(access, user) {
6216
6519
  return await evaluateAccess(access, { user }) !== false;
6217
6520
  }
6218
- async function serializeFields(fields, update2, localized) {
6521
+ async function serializeFields(fields, checks, localized) {
6522
+ const { update: update2 } = checks;
6219
6523
  const out = [];
6220
6524
  for (const field of fields) {
6221
- if (field.hidden) continue;
6525
+ if (field.hidden || !await checks.read.allows(field)) continue;
6222
6526
  const f = { name: field.name, type: field.type };
6223
6527
  if (field.label !== void 0) f.label = field.label;
6224
6528
  if (field.required) f.required = true;
@@ -6271,7 +6575,7 @@ async function serializeFields(fields, update2, localized) {
6271
6575
  case "array":
6272
6576
  if (field.minRows !== void 0) f.minRows = field.minRows;
6273
6577
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
6274
- f.fields = await serializeFields(field.fields, update2, localized);
6578
+ f.fields = await serializeFields(field.fields, checks, localized);
6275
6579
  break;
6276
6580
  case "blocks":
6277
6581
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -6280,28 +6584,31 @@ async function serializeFields(fields, update2, localized) {
6280
6584
  field.blocks.map(async (block) => ({
6281
6585
  slug: block.slug,
6282
6586
  ...block.labels ? { labels: block.labels } : {},
6283
- fields: await serializeFields(block.fields, update2, localized)
6587
+ fields: await serializeFields(block.fields, checks, localized)
6284
6588
  }))
6285
6589
  );
6286
6590
  break;
6287
6591
  case "group":
6288
- f.fields = await serializeFields(field.fields, update2, localized);
6592
+ f.fields = await serializeFields(field.fields, checks, localized);
6289
6593
  break;
6290
6594
  }
6291
6595
  out.push(f);
6292
6596
  }
6293
6597
  return out;
6294
6598
  }
6599
+ async function checkers(cms, user) {
6600
+ const rules = await cms.roles.fieldRules(user);
6601
+ return {
6602
+ update: new FieldAccessChecker("update", { user }, rules),
6603
+ read: new FieldAccessChecker("read", { user }, rules)
6604
+ };
6605
+ }
6295
6606
  async function may(cms, access, user, target, operation) {
6296
6607
  return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
6297
6608
  }
6298
6609
  async function collection(cms, config, user, localized, roles) {
6299
6610
  const target = { collection: config.slug };
6300
- const fields = await serializeFields(
6301
- config.fields,
6302
- new FieldAccessChecker("update", { user }),
6303
- localized
6304
- );
6611
+ const fields = await serializeFields(config.fields, await checkers(cms, user), localized);
6305
6612
  if (config.slug === USERS && cms.roles.enabled) {
6306
6613
  const role = fields.find((f) => f.name === "role");
6307
6614
  if (role) {
@@ -6331,23 +6638,22 @@ async function collection(cms, config, user, localized, roles) {
6331
6638
  if (config.admin?.group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
6332
6639
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
6333
6640
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
6641
+ const owner = cms.roles.ownerOf(config.slug);
6642
+ if (owner && fields.some((f) => f.name === owner)) result.owner = owner;
6334
6643
  if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
6335
6644
  result.editIn = "drawer";
6336
6645
  return result;
6337
6646
  }
6338
6647
  async function global(cms, config, user, localized) {
6339
6648
  const target = { global: config.slug };
6649
+ const checks = await checkers(cms, user);
6340
6650
  const result = {
6341
6651
  slug: config.slug,
6342
6652
  drafts: config.drafts === true,
6343
6653
  versions: Boolean(config.versions),
6344
6654
  preview: typeof config.preview === "function",
6345
6655
  schedule: config.schedule === true,
6346
- fields: await serializeFields(
6347
- config.fields,
6348
- new FieldAccessChecker("update", { user }),
6349
- localized
6350
- ),
6656
+ fields: await serializeFields(config.fields, checks, localized),
6351
6657
  permissions: {
6352
6658
  read: await may(cms, config.access?.read, user, target, "read"),
6353
6659
  update: await may(cms, config.access?.update, user, target, "update"),
@@ -6387,6 +6693,7 @@ async function adminSchema(cms, user, origin) {
6387
6693
  modules: adminModuleUrls(cms),
6388
6694
  uploadFromURL: cms.config.upload.fromURL !== void 0,
6389
6695
  views,
6696
+ rbac: cms.roles.enabled,
6390
6697
  ...views.deliveries ? {
6391
6698
  deliveries: {
6392
6699
  webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
@@ -6432,7 +6739,7 @@ async function widgets(cms, user) {
6432
6739
  }
6433
6740
 
6434
6741
  // src/version.ts
6435
- var VERSION = true ? "0.33.0" : "0.0.0-dev";
6742
+ var VERSION = true ? "0.34.0" : "0.0.0-dev";
6436
6743
 
6437
6744
  // src/rest/admin-status.ts
6438
6745
  var DAY = 864e5;
@@ -6923,6 +7230,14 @@ async function route(cms, ctx, method, segments, options) {
6923
7230
  }
6924
7231
  throw new HttpError("Not found", 404);
6925
7232
  }
7233
+ if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
7234
+ if (!ctx.user) throw new UnauthorizedError();
7235
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7236
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
7237
+ const parsed = parseId(third);
7238
+ if (parsed === void 0) throw new HttpError("Not found", 404);
7239
+ return { body: await cms.roles.owned(parsed) };
7240
+ }
6926
7241
  if (first === "admin" && second === "roles") {
6927
7242
  if (!ctx.user) throw new UnauthorizedError();
6928
7243
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -7127,7 +7442,9 @@ async function route(cms, ctx, method, segments, options) {
7127
7442
  return { body: doc };
7128
7443
  }
7129
7444
  if (method === "DELETE") {
7130
- return { body: await cms.delete(collection2, id, access) };
7445
+ const to = collection2 === USERS ? ctx.url.searchParams.get("transferTo") : null;
7446
+ const transferTo = to === null ? {} : { transferTo: to === "none" ? null : parseId(to) ?? to };
7447
+ return { body: await cms.delete(collection2, id, { ...access, ...transferTo }) };
7131
7448
  }
7132
7449
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
7133
7450
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",