@easy-cms/core 0.42.0 → 0.44.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 +114 -7
  2. package/dist/index.js +307 -130
  3. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -6,8 +6,19 @@ interface AuthUser {
6
6
  readonly id: ID;
7
7
  readonly email: string;
8
8
  readonly role: Role;
9
+ /**
10
+ * Set by `onRequest` (e.g. a multi-tenant plugin) when `role` holds only in part of the site,
11
+ * such as one tenant: an `admin` there administers that part, not the whole system (Settings,
12
+ * backups, roles, other users' API keys).
13
+ */
14
+ readonly scoped?: boolean;
9
15
  readonly [field: string]: unknown;
10
16
  }
17
+ /**
18
+ * An admin of the whole system: role `admin`, signed in (not an API key), and not an admin of a
19
+ * part only (`scoped`).
20
+ */
21
+ declare const isSystemAdmin: (user: AuthUser | null | undefined) => boolean;
11
22
  interface WhereOperators {
12
23
  readonly equals?: unknown;
13
24
  readonly not_equals?: unknown;
@@ -26,9 +37,16 @@ type Where = {
26
37
  } & {
27
38
  readonly [field: string]: WhereOperators | readonly Where[] | undefined;
28
39
  };
40
+ /**
41
+ * What a request is about besides its user, e.g. the site or tenant it works in: set by
42
+ * `onRequest` in the config (usually a plugin's), or passed to the Local API as `context`.
43
+ */
44
+ type RequestContext = Readonly<Record<string, unknown>>;
29
45
  interface AccessArgs {
30
46
  /** `null` when the request is not logged in. */
31
47
  readonly user: AuthUser | null;
48
+ /** The request's context (`onRequest`), `{}` when there is none. */
49
+ readonly context?: RequestContext;
32
50
  readonly id?: ID;
33
51
  readonly data?: Readonly<Record<string, unknown>>;
34
52
  }
@@ -1191,6 +1209,11 @@ interface AccessOptions {
1191
1209
  readonly overrideAccess?: boolean;
1192
1210
  /** The user to check access for. `null` = not logged in. */
1193
1211
  readonly user?: AuthUser | null;
1212
+ /**
1213
+ * What the call is about besides its user, e.g. the tenant (see `onRequest`): given to access
1214
+ * functions, hooks and `filterOptions`.
1215
+ */
1216
+ readonly context?: RequestContext;
1194
1217
  }
1195
1218
  interface DepthOptions extends AccessOptions {
1196
1219
  /** How many levels of relationships to populate. Default 1, maximum 3. */
@@ -1278,6 +1301,20 @@ declare class EasyCMS<C extends Config = Config> {
1278
1301
  private readonly webhooks;
1279
1302
  private readonly mailer;
1280
1303
  constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter, cwd?: string, privateStorage?: StorageAdapter | null);
1304
+ /**
1305
+ * The user and context of a request, as the REST API works them out: its session cookie or
1306
+ * Bearer token, then `onRequest`. For framework routes and server code that act for a visitor;
1307
+ * pass both to the Local API with `overrideAccess: false`.
1308
+ */
1309
+ forRequest(request: Request | Headers): Promise<{
1310
+ user: AuthUser | null;
1311
+ context: RequestContext;
1312
+ }>;
1313
+ /** @internal `onRequest` of the config, for a request whose user is known. */
1314
+ applyOnRequest(headers: Headers, url: URL | undefined, user: AuthUser | null): Promise<{
1315
+ user: AuthUser | null;
1316
+ context: RequestContext;
1317
+ }>;
1281
1318
  find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
1282
1319
  findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
1283
1320
  count<S extends Slug<C>>(collection: S, options?: {
@@ -1442,6 +1479,16 @@ declare class EasyCMS<C extends Config = Config> {
1442
1479
  transferTo?: ID | null;
1443
1480
  }): Promise<Doc<C, S>>;
1444
1481
  findGlobal<S extends GSlug<C>>(slug: S, options?: ReadOptions): Promise<GDoc<C, S>>;
1482
+ /** A global as stored under `key` (see `globalKey`); empty when there is no key. */
1483
+ private readGlobal;
1484
+ /**
1485
+ * Where a global is kept: its slug, or `<slug>@<scope>` for globals with `scope` (e.g. one per
1486
+ * tenant). `null` when the call has no scope: reads give the global empty. `scope` is given by
1487
+ * scheduled jobs, which have no request.
1488
+ */
1489
+ private globalKey;
1490
+ /** `globalKey` for changes, which need a scope. */
1491
+ private writeKey;
1445
1492
  updateGlobal<S extends GSlug<C>>(slug: S, data: GInput<C, S>, options?: DepthOptions): Promise<GDoc<C, S>>;
1446
1493
  /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
1447
1494
  unpublishGlobal<S extends GSlug<C>>(slug: S, options?: DepthOptions): Promise<GDoc<C, S>>;
@@ -1463,7 +1510,7 @@ declare class EasyCMS<C extends Config = Config> {
1463
1510
  * What `user` may do with one document. Resolves `where`-style access
1464
1511
  * against the document, so the admin UI can hide actions precisely.
1465
1512
  */
1466
- documentPermissions(collection: Slug<C>, id: ID, user: AuthUser | null): Promise<{
1513
+ documentPermissions(collection: Slug<C>, id: ID, user: AuthUser | null, context?: RequestContext): Promise<{
1467
1514
  update: boolean;
1468
1515
  delete: boolean;
1469
1516
  }>;
@@ -1693,6 +1740,12 @@ interface BaseField<TType extends string, TValue> {
1693
1740
  readonly label?: Label;
1694
1741
  readonly required?: boolean;
1695
1742
  readonly unique?: boolean;
1743
+ /**
1744
+ * With `unique` (or on a slug field): the name of a sibling field (e.g. `parent` or `tenant`)
1745
+ * whose documents only need to differ among themselves, so `/about/team` and `/careers/team`
1746
+ * can both be `team`. A single, unlocalized relationship, select, text or number field.
1747
+ */
1748
+ readonly uniqueWithin?: string;
1696
1749
  readonly index?: boolean;
1697
1750
  readonly defaultValue?: TValue;
1698
1751
  readonly validate?: FieldValidate<TValue>;
@@ -1743,11 +1796,6 @@ interface SelectField extends BaseField<'select', string | readonly string[]> {
1743
1796
  interface SlugField extends BaseField<'slug', string> {
1744
1797
  /** Name of a sibling `text` field to generate the slug from. */
1745
1798
  readonly from?: string;
1746
- /**
1747
- * Name of a sibling field (e.g. `parent`): slugs then only need to differ among documents
1748
- * with the same value there, so `/about/team` and `/careers/team` can both be `team`.
1749
- */
1750
- readonly uniqueWithin?: string;
1751
1799
  }
1752
1800
  /** Tiptap / ProseMirror JSON document. */
1753
1801
  interface RichTextDocument {
@@ -1783,6 +1831,8 @@ interface FilterOptionsArgs {
1783
1831
  readonly id: ID | undefined;
1784
1832
  readonly user: AuthUser | null;
1785
1833
  readonly cms: EasyCMS;
1834
+ /** The request's context (`onRequest`), `{}` when there is none. */
1835
+ readonly context: RequestContext;
1786
1836
  }
1787
1837
  /**
1788
1838
  * Which documents a relationship may point to: a `where` on the target collection, or `true`
@@ -2170,6 +2220,8 @@ interface HookBase {
2170
2220
  readonly cms: EasyCMS;
2171
2221
  /** Slug of the collection or global. */
2172
2222
  readonly slug: string;
2223
+ /** The request's context (`onRequest`, or `context` of a Local API call); `{}` when there is none. */
2224
+ readonly context: RequestContext;
2173
2225
  }
2174
2226
  type BeforeValidateHook = (args: HookBase & {
2175
2227
  data: Data$1;
@@ -2307,6 +2359,15 @@ interface GlobalConfig {
2307
2359
  readonly preview?: PreviewURL;
2308
2360
  readonly access?: GlobalAccess;
2309
2361
  readonly hooks?: GlobalHooks;
2362
+ /**
2363
+ * One value per scope, e.g. per tenant (usually set by a plugin): the scope of a call, from its
2364
+ * context (`onRequest`). A string keeps a value of its own; `undefined` the shared value; `null`
2365
+ * means the call has none, so reads give the global empty and changes are refused.
2366
+ */
2367
+ readonly scope?: (args: {
2368
+ readonly context: RequestContext;
2369
+ readonly user: AuthUser | null;
2370
+ }) => string | null | undefined;
2310
2371
  /** Custom admin components. */
2311
2372
  readonly admin?: ContainerAdmin;
2312
2373
  }
@@ -2382,6 +2443,23 @@ interface AdminConfig {
2382
2443
  readonly pages?: readonly AdminPage[];
2383
2444
  /** Panels on the dashboard, after the built-in ones. */
2384
2445
  readonly dashboard?: readonly DashboardWidget[];
2446
+ /**
2447
+ * A choice at the top of the menu that applies to everything in the admin, e.g. the tenant
2448
+ * (usually set by a plugin). The choice is kept in a cookie, which every request of the admin
2449
+ * sends; `onRequest` reads it.
2450
+ */
2451
+ readonly switcher?: AdminSwitcher | null;
2452
+ }
2453
+ /** See `AdminConfig.switcher`. */
2454
+ interface AdminSwitcher {
2455
+ /** Cookie that keeps the choice, e.g. `ecms-tenant`. */
2456
+ readonly cookie: string;
2457
+ readonly label: Label;
2458
+ /**
2459
+ * Path under the API that lists the choices for the logged-in user:
2460
+ * `{ options: [{ value, label }], all?: Label }`, with `all` to offer "all of them" (value `*`).
2461
+ */
2462
+ readonly options: string;
2385
2463
  }
2386
2464
  interface ImageSize {
2387
2465
  /** Key in `media.sizes`, e.g. `thumbnail`. */
@@ -2497,6 +2575,24 @@ interface AuthConfig {
2497
2575
  readonly passwordChanged?: PasswordEmailFn;
2498
2576
  };
2499
2577
  }
2578
+ /** What `onRequest` receives. */
2579
+ interface OnRequestArgs {
2580
+ readonly headers: Headers;
2581
+ /** The request's URL; `undefined` when only headers are known (`cms.forRequest(headers)`). */
2582
+ readonly url: URL | undefined;
2583
+ /** The user of the session or API key, before `onRequest` changes it; `null` when anonymous. */
2584
+ readonly user: AuthUser | null;
2585
+ readonly cms: EasyCMS;
2586
+ }
2587
+ /**
2588
+ * Runs on each API request (REST, plugin endpoints, `cms.forRequest`) after the user is known:
2589
+ * returns the request's `context` (e.g. the tenant it works in, from a header) and may change the
2590
+ * user (e.g. their role in that tenant, `scoped`). Plugins that set it call the one before.
2591
+ */
2592
+ type OnRequest = (args: OnRequestArgs) => MaybePromise<{
2593
+ readonly user?: AuthUser | null;
2594
+ readonly context?: RequestContext;
2595
+ } | undefined>;
2500
2596
  /** What an endpoint's handler receives. */
2501
2597
  interface EndpointRequest {
2502
2598
  readonly request: Request;
@@ -2505,6 +2601,8 @@ interface EndpointRequest {
2505
2601
  readonly params: Readonly<Record<string, string>>;
2506
2602
  /** The logged-in user (session cookie or Bearer token), or `null`. */
2507
2603
  readonly user: AuthUser | null;
2604
+ /** The request's context (`onRequest`): pass it to the Local API with `user`. */
2605
+ readonly context: RequestContext;
2508
2606
  /** The client's IP address, when the adapter knows it (e.g. to rate-limit a public form). */
2509
2607
  readonly ip: string | undefined;
2510
2608
  /** The Local API. Pass `{ user, overrideAccess: false }` to apply the user's access rules. */
@@ -2656,6 +2754,8 @@ interface Config {
2656
2754
  readonly endpoints?: readonly Endpoint[];
2657
2755
  /** Extra `easy-cms <name>` CLI commands, e.g. from plugins. */
2658
2756
  readonly commands?: readonly CliCommand[];
2757
+ /** The request's context and user, worked out on each API request (usually by a plugin). */
2758
+ readonly onRequest?: OnRequest;
2659
2759
  /**
2660
2760
  * Field types from packages, e.g. `color` from `@easy-cms/fields`: fields then use
2661
2761
  * `type: 'color'`. See `defineFieldType`.
@@ -2725,6 +2825,11 @@ interface ApiKeyPermissions {
2725
2825
  * or missing: every folder.
2726
2826
  */
2727
2827
  readonly folders?: readonly (string | number)[];
2828
+ /**
2829
+ * The context the key was created in (`onRequest`, e.g. its tenant), kept for good: requests
2830
+ * with the key get it as `user.apiKey.permissions.context`. Set by Easy CMS, not by input.
2831
+ */
2832
+ readonly context?: RequestContext;
2728
2833
  }
2729
2834
  /** Set on `AuthUser.apiKey` when a request authenticated with an API key. */
2730
2835
  interface ApiKeyContext {
@@ -3167,6 +3272,8 @@ interface AdminWidgetRef {
3167
3272
  }
3168
3273
  interface AdminSchema {
3169
3274
  locale: AdminLocale;
3275
+ /** A choice for the whole admin, e.g. the tenant (`admin.switcher`). */
3276
+ switcher: AdminSwitcher | null;
3170
3277
  /** Admins can email users links to set their password (needs `email` and `serverURL`). */
3171
3278
  passwordLinks: boolean;
3172
3279
  /** Menu order of collections (`admin.menu`); unlisted ones follow. */
@@ -3330,4 +3437,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
3330
3437
  /** The version of `@easy-cms/core`, set when the package is built. */
3331
3438
  declare const VERSION: string;
3332
3439
 
3333
- 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 AdminSso, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiHandlerOptions, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, 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 BuiltinMediaFoldersCollection, 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 DirectUpload, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, 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, type FolderLevel, type FolderPermissions, 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, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, 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, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, 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, createApiHandler, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, sharedEasyCMS, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword, writeBackupFile };
3440
+ 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 AdminSso, type AdminStatus, type AdminSwitcher, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiHandlerOptions, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, 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 BuiltinMediaFoldersCollection, 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 DirectUpload, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, 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, type FolderLevel, type FolderPermissions, 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, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type OnRequest, type OnRequestArgs, 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 RequestContext, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, 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, createApiHandler, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, isSystemAdmin, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, sharedEasyCMS, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword, writeBackupFile };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/access.ts
2
+ var isSystemAdmin = (user) => user?.role === "admin" && !user.apiKey && user.scoped !== true;
2
3
  var anyone = () => true;
3
4
  var isLoggedIn = ({ user }) => user !== null;
4
5
  var isAdmin = ({ user }) => user?.role === "admin";
@@ -41,8 +42,7 @@ function keyAllows(user, target, operation) {
41
42
  const allowed2 = key2.permissions.globals?.[target.global];
42
43
  return allowed2?.includes(operation) ?? false;
43
44
  }
44
- var isAdmin2 = (user) => user?.role === "admin";
45
- var ownKeys = ({ user }) => !user || user.apiKey ? false : isAdmin2(user) ? true : { user: { equals: user.id } };
45
+ var ownKeys = ({ user }) => !user || user.apiKey ? false : isSystemAdmin(user) ? true : { user: { equals: user.id } };
46
46
  var system = { update: () => false };
47
47
  var apiKeysCollection = {
48
48
  slug: API_KEYS,
@@ -99,14 +99,17 @@ var apiKeysCollection = {
99
99
  { name: "keyHash", type: "text", hidden: true }
100
100
  ],
101
101
  hooks: {
102
- // Only real collections, globals and operations are kept.
102
+ // Only real collections, globals and operations are kept. A key keeps the context it was
103
+ // created in (`createApiKey`), whatever its permissions are changed to.
103
104
  beforeChange: [
104
- ({ data, cms }) => data.permissions === void 0 ? data : {
105
- ...data,
106
- permissions: cleanPermissions(data.permissions, {
105
+ ({ data, cms, operation, originalDoc }) => {
106
+ if (data.permissions === void 0) return data;
107
+ const context = operation === "create" ? data.permissions.context : originalDoc?.permissions?.context;
108
+ const permissions = cleanPermissions(data.permissions, {
107
109
  collections: cms.config.collections.map((c) => c.slug),
108
110
  globals: cms.config.globals.map((g) => g.slug)
109
- })
111
+ });
112
+ return { ...data, permissions: context ? { ...permissions, context } : permissions };
110
113
  }
111
114
  ]
112
115
  }
@@ -183,11 +186,11 @@ function withCreatedBy(config) {
183
186
  }
184
187
  var adminOrSelf = ({ user }) => {
185
188
  if (!user) return false;
186
- if (user.role === "admin") return true;
189
+ if (isSystemAdmin(user)) return true;
187
190
  return { id: { equals: user.id } };
188
191
  };
189
192
  var adminOnly = {
190
- update: ({ user }) => user?.role === "admin"
193
+ update: ({ user }) => isSystemAdmin(user)
191
194
  };
192
195
  function userFields(roles, rbac) {
193
196
  const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
@@ -239,9 +242,9 @@ function withUsers(config) {
239
242
  fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
240
243
  access: {
241
244
  read: isLoggedIn,
242
- create: isAdmin,
245
+ create: ({ user }) => isSystemAdmin(user),
243
246
  update: adminOrSelf,
244
- delete: isAdmin,
247
+ delete: ({ user }) => isSystemAdmin(user),
245
248
  ...custom?.access
246
249
  }
247
250
  };
@@ -421,13 +424,28 @@ function withMedia(config) {
421
424
  ]
422
425
  }
423
426
  };
424
- const others = (config.collections ?? []).filter((c) => c.slug !== MEDIA);
427
+ const customFolders = config.collections?.find((c) => c.slug === MEDIA_FOLDERS);
428
+ const mediaFolders = customFolders ? {
429
+ ...mediaFoldersCollection,
430
+ fields: [...mediaFoldersCollection.fields, ...customFolders.fields],
431
+ access: { ...mediaFoldersCollection.access, ...customFolders.access },
432
+ hooks: {
433
+ ...customFolders.hooks,
434
+ afterRead: [
435
+ ...mediaFoldersCollection.hooks?.afterRead ?? [],
436
+ ...customFolders.hooks?.afterRead ?? []
437
+ ]
438
+ }
439
+ } : mediaFoldersCollection;
440
+ const others = (config.collections ?? []).filter(
441
+ (c) => c.slug !== MEDIA && c.slug !== MEDIA_FOLDERS
442
+ );
425
443
  return {
426
444
  ...config,
427
445
  collections: [
428
446
  ...others.slice(0, 1),
429
447
  media,
430
- ...folders ? [mediaFoldersCollection] : [],
448
+ ...folders ? [mediaFolders] : [],
431
449
  ...others.slice(1)
432
450
  ]
433
451
  };
@@ -4079,7 +4097,7 @@ var Roles = class {
4079
4097
  * it; without roles, nobody else (`status`, `deliveries`) or everybody (plugin pages).
4080
4098
  */
4081
4099
  async canView(user, id) {
4082
- if (user.role === "admin" && !user.apiKey) return true;
4100
+ if (isSystemAdmin(user)) return true;
4083
4101
  if (user.apiKey) return false;
4084
4102
  if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
4085
4103
  return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
@@ -4866,6 +4884,14 @@ function validateAdmin(config, add) {
4866
4884
  "e.g. siteUrl: 'https://example.com'"
4867
4885
  );
4868
4886
  }
4887
+ const switcher = admin.switcher;
4888
+ if (switcher !== void 0 && switcher !== null) {
4889
+ const { cookie: cookie2, options } = switcher;
4890
+ if (typeof cookie2 !== "string" || !/^[A-Za-z0-9_-]+$/.test(cookie2))
4891
+ add("admin.switcher.cookie", "must be a cookie name (letters, digits, - and _)");
4892
+ if (typeof options !== "string" || !options.startsWith("/"))
4893
+ add("admin.switcher.options", 'must be a path under the API starting with "/"');
4894
+ }
4869
4895
  const modules = admin.modules;
4870
4896
  if (modules !== void 0) {
4871
4897
  if (!Array.isArray(modules)) {
@@ -5501,6 +5527,19 @@ function validateField(field, path, siblings, collectionSlugs, add) {
5501
5527
  if (cell !== void 0) validateComponent(cell, `${path}.admin.cell`, add);
5502
5528
  }
5503
5529
  }
5530
+ if (field.uniqueWithin !== void 0) {
5531
+ const scope = siblings.get(field.uniqueWithin);
5532
+ if (!field.unique && field.type !== "slug") {
5533
+ add(`${path}.uniqueWithin`, "needs `unique: true` (or a slug field)");
5534
+ } else if (!scope || scope === field) {
5535
+ add(`${path}.uniqueWithin`, `no sibling field named "${field.uniqueWithin}"`);
5536
+ } else if (!["relationship", "select", "text", "number"].includes(scope.type) || "hasMany" in scope && scope.hasMany || scope.localized) {
5537
+ add(
5538
+ `${path}.uniqueWithin`,
5539
+ `"${field.uniqueWithin}" must be a single, unlocalized relationship, select, text or number field`
5540
+ );
5541
+ }
5542
+ }
5504
5543
  switch (field.type) {
5505
5544
  case "text":
5506
5545
  case "textarea":
@@ -5521,17 +5560,6 @@ function validateField(field, path, siblings, collectionSlugs, add) {
5521
5560
  add(`${path}.from`, `"${field.from}" must be a text field (got ${source.type})`);
5522
5561
  }
5523
5562
  }
5524
- if (field.uniqueWithin !== void 0) {
5525
- const scope = siblings.get(field.uniqueWithin);
5526
- if (!scope || scope === field) {
5527
- add(`${path}.uniqueWithin`, `no sibling field named "${field.uniqueWithin}"`);
5528
- } else if (!["relationship", "select", "text", "number"].includes(scope.type) || "hasMany" in scope && scope.hasMany || scope.localized) {
5529
- add(
5530
- `${path}.uniqueWithin`,
5531
- `"${field.uniqueWithin}" must be a single, unlocalized relationship, select, text or number field`
5532
- );
5533
- }
5534
- }
5535
5563
  break;
5536
5564
  case "relationship":
5537
5565
  if (typeof field.to !== "string" || !collectionSlugs.has(field.to)) {
@@ -5822,7 +5850,8 @@ async function resolveConfig(input) {
5822
5850
  menu: config.admin?.menu ?? [],
5823
5851
  modules: config.admin?.modules ?? [],
5824
5852
  pages: config.admin?.pages ?? [],
5825
- dashboard: config.admin?.dashboard ?? []
5853
+ dashboard: config.admin?.dashboard ?? [],
5854
+ switcher: config.admin?.switcher ?? null
5826
5855
  },
5827
5856
  upload: {
5828
5857
  dir: config.upload?.dir ?? "uploads",
@@ -5969,6 +5998,29 @@ var EasyCMS = class {
5969
5998
  );
5970
5999
  this.auth = new Auth(this);
5971
6000
  }
6001
+ /**
6002
+ * The user and context of a request, as the REST API works them out: its session cookie or
6003
+ * Bearer token, then `onRequest`. For framework routes and server code that act for a visitor;
6004
+ * pass both to the Local API with `overrideAccess: false`.
6005
+ */
6006
+ async forRequest(request) {
6007
+ const headers = request instanceof Headers ? request : request.headers;
6008
+ const url = request instanceof Headers ? void 0 : new URL(request.url);
6009
+ return this.applyOnRequest(headers, url, await this.auth.userFromHeaders(headers));
6010
+ }
6011
+ /** @internal `onRequest` of the config, for a request whose user is known. */
6012
+ async applyOnRequest(headers, url, user) {
6013
+ const result = await this.config.onRequest?.({
6014
+ headers,
6015
+ url,
6016
+ user,
6017
+ cms: this
6018
+ });
6019
+ return {
6020
+ user: result?.user !== void 0 ? result.user : user,
6021
+ context: result?.context ?? NO_CONTEXT
6022
+ };
6023
+ }
5972
6024
  async find(collection2, options = {}) {
5973
6025
  const config = this.collection(collection2);
5974
6026
  const guard = guardOf(options);
@@ -6222,6 +6274,7 @@ var EasyCMS = class {
6222
6274
  await this.checkGrant(guard, { collection: MEDIA }, "create");
6223
6275
  const allowed2 = await evaluateAccess(this.collection(MEDIA).access?.create, {
6224
6276
  user: guard.user,
6277
+ context: guard.context,
6225
6278
  data
6226
6279
  });
6227
6280
  if (allowed2 !== true) throw deny(guard.user);
@@ -6259,7 +6312,11 @@ var EasyCMS = class {
6259
6312
  const fromURL = this.config.upload.fromURL;
6260
6313
  if (guard.enforce) {
6261
6314
  await this.checkGrant(guard, { collection: MEDIA }, "create");
6262
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
6315
+ const allowed2 = await evaluateAccess(config.access?.create, {
6316
+ user: guard.user,
6317
+ context: guard.context,
6318
+ data
6319
+ });
6263
6320
  if (allowed2 !== true) throw deny(guard.user);
6264
6321
  if (!fromURL)
6265
6322
  throw new ValidationError(MEDIA, [
@@ -6291,7 +6348,10 @@ var EasyCMS = class {
6291
6348
  const config = this.collection(API_KEYS);
6292
6349
  const guard = guardOf(options);
6293
6350
  if (guard.enforce) {
6294
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user });
6351
+ const allowed2 = await evaluateAccess(config.access?.create, {
6352
+ user: guard.user,
6353
+ context: guard.context
6354
+ });
6295
6355
  if (allowed2 !== true) throw deny(guard.user);
6296
6356
  }
6297
6357
  const owner = guard.enforce ? guard.user?.id : data.user;
@@ -6303,7 +6363,12 @@ var EasyCMS = class {
6303
6363
  config,
6304
6364
  {
6305
6365
  name: data.name,
6306
- permissions: data.permissions ?? {},
6366
+ // The context it is created in (e.g. a tenant) stays with the key. Requests can't name
6367
+ // one; trusted calls may.
6368
+ permissions: {
6369
+ ...withoutContext(data.permissions),
6370
+ ...guard.enforce ? Object.keys(guard.context).length > 0 ? { context: guard.context } : {} : data.permissions?.context ? { context: data.permissions.context } : {}
6371
+ },
6307
6372
  expiresAt: expiresAt ?? null,
6308
6373
  prefix,
6309
6374
  user: owner
@@ -6564,7 +6629,11 @@ var EasyCMS = class {
6564
6629
  if (id === null) {
6565
6630
  if (guard.enforce) {
6566
6631
  await this.checkGrant(guard, { collection: collection2 }, "create");
6567
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
6632
+ const allowed2 = await evaluateAccess(config.access?.create, {
6633
+ user: guard.user,
6634
+ context: guard.context,
6635
+ data: raw
6636
+ });
6568
6637
  if (allowed2 !== true) throw deny(guard.user);
6569
6638
  }
6570
6639
  } else {
@@ -6620,8 +6689,9 @@ var EasyCMS = class {
6620
6689
  raw,
6621
6690
  await this.fieldChecker("update", guard, void 0, raw)
6622
6691
  );
6623
- const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
6624
- const current = await this.globalDraft(config, saved);
6692
+ const key2 = this.globalKey(config, guard);
6693
+ const saved = (key2 ? await this.db.findGlobal({ slug: key2 }) : null) ?? {};
6694
+ const current = await this.globalDraft(config, saved, key2);
6625
6695
  const merged = generateSlugs(
6626
6696
  config.fields,
6627
6697
  applyDefaults(
@@ -6719,7 +6789,7 @@ var EasyCMS = class {
6719
6789
  "update",
6720
6790
  parsed,
6721
6791
  options,
6722
- guard.user
6792
+ guard
6723
6793
  );
6724
6794
  prepared = await this.transform(
6725
6795
  config.hooks?.beforeChange,
@@ -6853,8 +6923,12 @@ var EasyCMS = class {
6853
6923
  const config = this.global(slug2);
6854
6924
  const guard = guardOf(options);
6855
6925
  await this.checkGlobalAccess(config, "read", guard);
6856
- const saved = await this.db.findGlobal({ slug: slug2 });
6857
- const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
6926
+ return this.readGlobal(config, this.globalKey(config, guard), guard, options);
6927
+ }
6928
+ /** A global as stored under `key` (see `globalKey`); empty when there is no key. */
6929
+ async readGlobal(config, key2, guard, options) {
6930
+ const saved = key2 ? await this.db.findGlobal({ slug: key2 }) : null;
6931
+ const stored = saved && options.draft ? await this.globalDraft(config, saved, key2) : saved;
6858
6932
  const data = fillMissing(
6859
6933
  config.fields,
6860
6934
  applyDefaults(config.fields, stored ?? {}, this.config.localization)
@@ -6867,6 +6941,27 @@ var EasyCMS = class {
6867
6941
  const { id: _id, ...doc } = out;
6868
6942
  return doc;
6869
6943
  }
6944
+ /**
6945
+ * Where a global is kept: its slug, or `<slug>@<scope>` for globals with `scope` (e.g. one per
6946
+ * tenant). `null` when the call has no scope: reads give the global empty. `scope` is given by
6947
+ * scheduled jobs, which have no request.
6948
+ */
6949
+ globalKey(config, guard, scope) {
6950
+ if (scope !== void 0) return `${config.slug}@${scope}`;
6951
+ if (!config.scope) return config.slug;
6952
+ const own = config.scope({ context: guard.context, user: guard.user });
6953
+ if (own === void 0) return config.slug;
6954
+ return own === null ? null : `${config.slug}@${own}`;
6955
+ }
6956
+ /** `globalKey` for changes, which need a scope. */
6957
+ writeKey(config, guard, scope) {
6958
+ const key2 = this.globalKey(config, guard, scope);
6959
+ if (key2 === null)
6960
+ throw new QueryError(
6961
+ `"${config.slug}" has a value per scope (e.g. per tenant): choose one to change it`
6962
+ );
6963
+ return key2;
6964
+ }
6870
6965
  async updateGlobal(slug2, data, options = {}) {
6871
6966
  return this.saveGlobal(slug2, asObject(data, slug2), options);
6872
6967
  }
@@ -6880,20 +6975,21 @@ var EasyCMS = class {
6880
6975
  const config = this.global(slug2);
6881
6976
  const guard = guardOf(options);
6882
6977
  await this.checkGlobalAccess(config, "update", guard);
6883
- const saved = await this.db.findGlobal({ slug: slug2 });
6884
- if (saved && await this.globalDraft(config, saved) !== saved) {
6885
- await this.saveVersion(config, globalParent(slug2), 0, saved, guard);
6978
+ const key2 = this.writeKey(config, guard);
6979
+ const saved = await this.db.findGlobal({ slug: key2 });
6980
+ if (saved && await this.globalDraft(config, saved, key2) !== saved) {
6981
+ await this.saveVersion(config, globalParent(key2), 0, saved, guard);
6886
6982
  }
6887
6983
  return this.findGlobal(slug2, { ...options, draft: true });
6888
6984
  }
6889
6985
  /** Saved versions of a global, newest first. Needs update access. */
6890
6986
  async findGlobalVersions(slug2, options = {}) {
6891
- await this.globalVersionTarget(slug2, options);
6892
- return this.versions.list(globalParent(slug2), 0, options.limit ?? 20, options.page ?? 1);
6987
+ const { key: key2 } = await this.globalVersionTarget(slug2, options);
6988
+ return this.versions.list(globalParent(key2), 0, options.limit ?? 20, options.page ?? 1);
6893
6989
  }
6894
6990
  async findGlobalVersion(slug2, versionId, options = {}) {
6895
- const config = await this.globalVersionTarget(slug2, options);
6896
- const version = await this.versions.get(globalParent(slug2), 0, versionId);
6991
+ const { config, key: key2 } = await this.globalVersionTarget(slug2, options);
6992
+ const version = await this.versions.get(globalParent(key2), 0, versionId);
6897
6993
  if (!version) return null;
6898
6994
  const data = fillMissing(
6899
6995
  config.fields,
@@ -6908,8 +7004,8 @@ var EasyCMS = class {
6908
7004
  }
6909
7005
  /** Makes an old version of a global current (as a draft when the global has drafts). */
6910
7006
  async restoreGlobalVersion(slug2, versionId, options = {}) {
6911
- const config = await this.globalVersionTarget(slug2, options);
6912
- const version = await this.versions.get(globalParent(slug2), 0, versionId);
7007
+ const { config, key: key2 } = await this.globalVersionTarget(slug2, options);
7008
+ const version = await this.versions.get(globalParent(key2), 0, versionId);
6913
7009
  if (!version) throw new NotFoundError(`${slug2} version`, versionId);
6914
7010
  const { status: _status, ...data } = version.data;
6915
7011
  return this.saveGlobal(
@@ -6922,20 +7018,22 @@ var EasyCMS = class {
6922
7018
  async globalVersionTarget(slug2, options) {
6923
7019
  const config = this.global(slug2);
6924
7020
  if (!versionLimit(config)) throw new QueryError(`"${slug2}" has no versions`);
6925
- await this.checkGlobalAccess(config, "update", guardOf(options));
6926
- return config;
7021
+ const guard = guardOf(options);
7022
+ await this.checkGlobalAccess(config, "update", guard);
7023
+ return { config, key: this.writeKey(config, guard) };
6927
7024
  }
6928
7025
  /** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
6929
- async globalDraft(config, saved) {
6930
- if (!this.separateDrafts(config) || saved.status !== "published") return saved;
6931
- const version = (await this.versions.latest(globalParent(config.slug), [0])).get("0");
7026
+ async globalDraft(config, saved, key2) {
7027
+ if (!key2 || !this.separateDrafts(config) || saved.status !== "published") return saved;
7028
+ const version = (await this.versions.latest(globalParent(key2), [0])).get("0");
6932
7029
  if (version?.status !== "draft") return saved;
6933
7030
  return { ...version.data, updatedAt: version.createdAt, status: "draft" };
6934
7031
  }
6935
- async saveGlobal(slug2, raw, options, mode = "save") {
7032
+ async saveGlobal(slug2, raw, options, mode = "save", scope) {
6936
7033
  const config = this.global(slug2);
6937
7034
  const guard = guardOf(options);
6938
7035
  await this.checkGlobalAccess(config, "update", guard);
7036
+ const key2 = this.writeKey(config, guard, scope);
6939
7037
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
6940
7038
  await this.checkGrant(guard, { global: slug2 }, "publish");
6941
7039
  const input = await filterInput(
@@ -6943,8 +7041,8 @@ var EasyCMS = class {
6943
7041
  raw,
6944
7042
  await this.fieldChecker("update", guard, void 0, raw)
6945
7043
  );
6946
- const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
6947
- const current = await this.globalDraft(config, existing);
7044
+ const existing = await this.db.findGlobal({ slug: key2 }) ?? {};
7045
+ const current = await this.globalDraft(config, existing, key2);
6948
7046
  const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
6949
7047
  const merged = generateSlugs(
6950
7048
  config.fields,
@@ -6957,7 +7055,7 @@ var EasyCMS = class {
6957
7055
  );
6958
7056
  if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
6959
7057
  const base = this.hookArgs(config, guard);
6960
- let prepared = await this.prepare(config, merged, "update", void 0, options, guard.user);
7058
+ let prepared = await this.prepare(config, merged, "update", void 0, options, guard);
6961
7059
  prepared = await this.transform(
6962
7060
  config.hooks?.beforeChange,
6963
7061
  "data",
@@ -6967,7 +7065,7 @@ var EasyCMS = class {
6967
7065
  const now = (/* @__PURE__ */ new Date()).toISOString();
6968
7066
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
6969
7067
  const draft = { ...prepared, updatedAt: now };
6970
- await this.saveVersion(config, globalParent(slug2), 0, draft, guard);
7068
+ await this.saveVersion(config, globalParent(key2), 0, draft, guard);
6971
7069
  await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
6972
7070
  ...base,
6973
7071
  doc: draft,
@@ -6976,10 +7074,10 @@ var EasyCMS = class {
6976
7074
  });
6977
7075
  this.emit(config, "draft", draft);
6978
7076
  await this.audited(config, "draft", draft, current, guard, mode);
6979
- return this.findGlobal(slug2, { ...options, draft: true });
7077
+ return this.readGlobal(config, key2, guard, { ...options, draft: true });
6980
7078
  }
6981
- const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
6982
- await this.saveVersion(config, globalParent(slug2), 0, doc, guard);
7079
+ const doc = await this.db.updateGlobal({ slug: key2, data: { ...prepared, updatedAt: now } });
7080
+ await this.saveVersion(config, globalParent(key2), 0, doc, guard);
6983
7081
  await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
6984
7082
  ...base,
6985
7083
  doc,
@@ -6988,17 +7086,17 @@ var EasyCMS = class {
6988
7086
  });
6989
7087
  this.emit(config, "update", doc, existing.status);
6990
7088
  await this.audited(config, "update", doc, current, guard, mode);
6991
- return this.findGlobal(slug2, { ...options, draft: true });
7089
+ return this.readGlobal(config, key2, guard, { ...options, draft: true });
6992
7090
  }
6993
7091
  /**
6994
7092
  * What `user` may do with one document. Resolves `where`-style access
6995
7093
  * against the document, so the admin UI can hide actions precisely.
6996
7094
  */
6997
- async documentPermissions(collection2, id, user) {
7095
+ async documentPermissions(collection2, id, user, context = NO_CONTEXT) {
6998
7096
  const config = this.collection(collection2);
6999
7097
  const parsed = parseId(id);
7000
7098
  if (parsed === void 0) return { update: false, delete: false };
7001
- const guard = { enforce: true, user };
7099
+ const guard = { enforce: true, user, context };
7002
7100
  const check2 = (operation) => this.checkDocumentAccess(config, operation, guard, parsed, void 0).then(
7003
7101
  () => true,
7004
7102
  (error) => {
@@ -7166,9 +7264,10 @@ var EasyCMS = class {
7166
7264
  const action = row.action;
7167
7265
  try {
7168
7266
  if (parent.startsWith("global:")) {
7169
- const slug2 = parent.slice("global:".length);
7170
- if (action === "publish") await this.saveGlobal(slug2, { status: "published" }, {});
7171
- else await this.saveGlobal(slug2, { status: "draft" }, {}, "unpublish");
7267
+ const { slug: slug2, scope } = splitGlobalKey(parent.slice("global:".length));
7268
+ if (action === "publish")
7269
+ await this.saveGlobal(slug2, { status: "published" }, {}, "save", scope);
7270
+ else await this.saveGlobal(slug2, { status: "draft" }, {}, "unpublish", scope);
7172
7271
  } else if (action === "publish") {
7173
7272
  await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
7174
7273
  } else {
@@ -7213,16 +7312,16 @@ var EasyCMS = class {
7213
7312
  await this.removeJob(collection2, parsed, jobId);
7214
7313
  }
7215
7314
  async scheduleGlobal(slug2, job, options = {}) {
7216
- await this.globalScheduleTarget(slug2, options);
7217
- return this.addJob(`global:${slug2}`, 0, job, options);
7315
+ const key2 = await this.globalScheduleTarget(slug2, options);
7316
+ return this.addJob(`global:${key2}`, 0, job, options);
7218
7317
  }
7219
7318
  async scheduledGlobal(slug2, options = {}) {
7220
- await this.globalScheduleTarget(slug2, options);
7221
- return this.pendingJobs(`global:${slug2}`, 0);
7319
+ const key2 = await this.globalScheduleTarget(slug2, options);
7320
+ return this.pendingJobs(`global:${key2}`, 0);
7222
7321
  }
7223
7322
  async cancelGlobalSchedule(slug2, jobId, options = {}) {
7224
- await this.globalScheduleTarget(slug2, options);
7225
- await this.removeJob(`global:${slug2}`, 0, jobId);
7323
+ const key2 = await this.globalScheduleTarget(slug2, options);
7324
+ await this.removeJob(`global:${key2}`, 0, jobId);
7226
7325
  }
7227
7326
  async scheduleTarget(collection2, id, options) {
7228
7327
  const config = this.collection(collection2);
@@ -7237,8 +7336,10 @@ var EasyCMS = class {
7237
7336
  async globalScheduleTarget(slug2, options) {
7238
7337
  const config = this.global(slug2);
7239
7338
  if (!config.schedule) throw new QueryError(`"${slug2}" has no schedule`);
7240
- await this.checkGlobalAccess(config, "update", guardOf(options));
7241
- await this.checkGrant(guardOf(options), { global: slug2 }, "publish");
7339
+ const guard = guardOf(options);
7340
+ await this.checkGlobalAccess(config, "update", guard);
7341
+ await this.checkGrant(guard, { global: slug2 }, "publish");
7342
+ return this.writeKey(config, guard);
7242
7343
  }
7243
7344
  async addJob(parent, doc, job, options) {
7244
7345
  const runAt = new Date(job.at);
@@ -7295,9 +7396,16 @@ var EasyCMS = class {
7295
7396
  if (!guard.enforce) return true;
7296
7397
  const cached = allowed2.get(parent);
7297
7398
  if (cached !== void 0) return cached;
7298
- const config = parent.startsWith("global:") ? this.config.globals.find((g) => g.slug === parent.slice("global:".length)) : this.config.collections.find((c) => c.slug === parent);
7299
- const access = config ? await evaluateAccess(config.access?.update, { user: guard.user }) : false;
7300
- const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
7399
+ const global2 = parent.startsWith("global:") ? this.config.globals.find(
7400
+ (g) => g.slug === splitGlobalKey(parent.slice("global:".length)).slug
7401
+ ) : void 0;
7402
+ if (global2 && this.globalKey(global2, guard) !== parent.slice("global:".length)) {
7403
+ allowed2.set(parent, false);
7404
+ return false;
7405
+ }
7406
+ const config = parent.startsWith("global:") ? global2 : this.config.collections.find((c) => c.slug === parent);
7407
+ const access = config ? await evaluateAccess(config.access?.update, { user: guard.user, context: guard.context }) : false;
7408
+ const target = parent.startsWith("global:") ? { global: splitGlobalKey(parent.slice("global:".length)).slug } : { collection: parent };
7301
7409
  const result = access !== false && await this.roles.allows(guard.user, target, "update") !== false;
7302
7410
  allowed2.set(parent, result);
7303
7411
  return result;
@@ -7306,7 +7414,7 @@ var EasyCMS = class {
7306
7414
  for (const row of rows.docs) {
7307
7415
  const parent = String(row.parent);
7308
7416
  if (!await may2(parent)) continue;
7309
- const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
7417
+ const target = parent.startsWith("global:") ? { global: splitGlobalKey(parent.slice("global:".length)).slug } : { collection: parent };
7310
7418
  jobs.push({ ...toJob(row), ...target, doc: row.doc });
7311
7419
  if (jobs.length === limit) break;
7312
7420
  }
@@ -7418,7 +7526,10 @@ var EasyCMS = class {
7418
7526
  async readWhere(config, guard, where) {
7419
7527
  if (!guard.enforce) return where;
7420
7528
  const own = await this.checkGrant(guard, { collection: config.slug }, "read");
7421
- const access = await evaluateAccess(config.access?.read, { user: guard.user });
7529
+ const access = await evaluateAccess(config.access?.read, {
7530
+ user: guard.user,
7531
+ context: guard.context
7532
+ });
7422
7533
  if (access === false) throw deny(guard.user);
7423
7534
  await this.checkQueryFields(config, guard, where);
7424
7535
  return andWhere(andWhere(where, access), own);
@@ -7428,6 +7539,7 @@ var EasyCMS = class {
7428
7539
  const own = await this.checkGrant(guard, { collection: config.slug }, operation);
7429
7540
  const allowed2 = await evaluateAccess(config.access?.[operation], {
7430
7541
  user: guard.user,
7542
+ context: guard.context,
7431
7543
  id,
7432
7544
  ...data ? { data } : {}
7433
7545
  });
@@ -7445,7 +7557,10 @@ var EasyCMS = class {
7445
7557
  async checkGlobalAccess(config, operation, guard) {
7446
7558
  if (!guard.enforce) return;
7447
7559
  await this.checkGrant(guard, { global: config.slug }, operation);
7448
- const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
7560
+ const access = await evaluateAccess(config.access?.[operation], {
7561
+ user: guard.user,
7562
+ context: guard.context
7563
+ });
7449
7564
  if (typeof access === "object")
7450
7565
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
7451
7566
  if (!access) throw deny(guard.user);
@@ -7456,6 +7571,7 @@ var EasyCMS = class {
7456
7571
  kind,
7457
7572
  {
7458
7573
  user: guard.user,
7574
+ context: guard.context,
7459
7575
  ...id !== void 0 ? { id } : {},
7460
7576
  ...data ? { data } : {}
7461
7577
  },
@@ -7536,7 +7652,11 @@ var EasyCMS = class {
7536
7652
  await this.checkGrant(guard, { collection: collection2 }, "create");
7537
7653
  if (config.drafts && raw.status === "published")
7538
7654
  await this.checkGrant(guard, { collection: collection2 }, "publish");
7539
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
7655
+ const allowed2 = await evaluateAccess(config.access?.create, {
7656
+ user: guard.user,
7657
+ context: guard.context,
7658
+ data: raw
7659
+ });
7540
7660
  if (typeof allowed2 === "object")
7541
7661
  throw new QueryError(`create access of "${collection2}" must return a boolean`);
7542
7662
  if (!allowed2) throw deny(guard.user);
@@ -7568,7 +7688,7 @@ var EasyCMS = class {
7568
7688
  "create",
7569
7689
  void 0,
7570
7690
  options,
7571
- (hookGuard ?? guard).user
7691
+ hookGuard ?? guard
7572
7692
  );
7573
7693
  prepared = await this.transform(
7574
7694
  config.hooks?.beforeChange,
@@ -7706,7 +7826,12 @@ var EasyCMS = class {
7706
7826
  });
7707
7827
  }
7708
7828
  hookArgs(config, guard) {
7709
- return { user: guard.user, cms: this, slug: config.slug };
7829
+ return {
7830
+ user: guard.user,
7831
+ cms: this,
7832
+ slug: config.slug,
7833
+ context: guard.context
7834
+ };
7710
7835
  }
7711
7836
  /**
7712
7837
  * Runs hooks that may replace a value, passed as `data` (before hooks) or `doc` (afterRead).
@@ -7768,7 +7893,7 @@ var EasyCMS = class {
7768
7893
  return result;
7769
7894
  }
7770
7895
  /** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
7771
- async prepare(config, data, operation, selfId, options = {}, user = null) {
7896
+ async prepare(config, data, operation, selfId, options = {}, guard = { user: null, context: NO_CONTEXT }) {
7772
7897
  const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
7773
7898
  const locale = this.localeOf(options);
7774
7899
  const result = await validateFields(config.fields, data, {
@@ -7796,7 +7921,7 @@ var EasyCMS = class {
7796
7921
  }
7797
7922
  errors.push(...await this.checkReferences(result.references));
7798
7923
  if (errors.length === 0)
7799
- errors.push(...await this.checkFilterOptions(result.references, selfId, user));
7924
+ errors.push(...await this.checkFilterOptions(result.references, selfId, guard));
7800
7925
  if (errors.length > 0) throw new ValidationError(config.slug, errors);
7801
7926
  return clean2;
7802
7927
  }
@@ -7855,9 +7980,10 @@ var EasyCMS = class {
7855
7980
  locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
7856
7981
  v
7857
7982
  ]) : [[field.name, field.name, value]];
7983
+ const scope = field.uniqueWithin === void 0 ? void 0 : { [field.uniqueWithin]: { equals: data[field.uniqueWithin] ?? null } };
7858
7984
  for (const [path, errorField, v] of checks) {
7859
7985
  if (v === null || v === void 0) continue;
7860
- if (await this.isTaken(config.slug, path, v, selfId)) {
7986
+ if (await this.isTaken(config.slug, path, v, selfId, scope)) {
7861
7987
  errors.push({ field: errorField, message: "must be unique" });
7862
7988
  }
7863
7989
  }
@@ -7875,14 +8001,16 @@ var EasyCMS = class {
7875
8001
  return await this.db.count({ collection: collection2, where }) > 0;
7876
8002
  }
7877
8003
  /** Each reference is one its relationship's `filterOptions` allow. */
7878
- async checkFilterOptions(references2, selfId, user) {
8004
+ async checkFilterOptions(references2, selfId, guard) {
7879
8005
  const errors = [];
7880
8006
  const cms = this;
8007
+ const { user, context } = guard;
7881
8008
  const allowed2 = /* @__PURE__ */ new Map();
7882
8009
  for (const ref of references2) {
7883
8010
  const filter = ref.filterOptions;
7884
8011
  if (!filter) continue;
7885
- if (!allowed2.has(filter)) allowed2.set(filter, await filter({ id: selfId, user, cms }));
8012
+ if (!allowed2.has(filter))
8013
+ allowed2.set(filter, await filter({ id: selfId, user, cms, context }));
7886
8014
  const where = allowed2.get(filter);
7887
8015
  if (where === true) continue;
7888
8016
  const target = this.config.collections.find((c) => c.slug === ref.collection);
@@ -7968,8 +8096,21 @@ function wherePaths(where) {
7968
8096
  }
7969
8097
  return out;
7970
8098
  }
8099
+ var NO_CONTEXT = Object.freeze({});
8100
+ function withoutContext(permissions) {
8101
+ const { context: _context, ...rest } = permissions ?? {};
8102
+ return rest;
8103
+ }
8104
+ function splitGlobalKey(key2) {
8105
+ const at = key2.indexOf("@");
8106
+ return at < 0 ? { slug: key2 } : { slug: key2.slice(0, at), scope: key2.slice(at + 1) };
8107
+ }
7971
8108
  function guardOf(options) {
7972
- return { enforce: options.overrideAccess === false, user: options.user ?? null };
8109
+ return {
8110
+ enforce: options.overrideAccess === false,
8111
+ user: options.user ?? null,
8112
+ context: options.context ?? NO_CONTEXT
8113
+ };
7973
8114
  }
7974
8115
  function deny(user) {
7975
8116
  return user ? new ForbiddenError() : new UnauthorizedError();
@@ -8256,8 +8397,8 @@ function componentRef(component) {
8256
8397
  if (typeof component === "string") return { tag: component };
8257
8398
  return component.props ? { tag: component.tag, props: JSON.parse(JSON.stringify(component.props)) } : { tag: component.tag };
8258
8399
  }
8259
- async function allowed(access, user) {
8260
- return await evaluateAccess(access, { user }) !== false;
8400
+ async function allowed(access, user, context) {
8401
+ return await evaluateAccess(access, { user, context }) !== false;
8261
8402
  }
8262
8403
  async function serializeFields(fields, checks, localized, folderId) {
8263
8404
  const { update: update2 } = checks;
@@ -8339,21 +8480,21 @@ async function serializeFields(fields, checks, localized, folderId) {
8339
8480
  }
8340
8481
  return out;
8341
8482
  }
8342
- async function checkers(cms, user) {
8483
+ async function checkers(cms, user, context) {
8343
8484
  const rules = await cms.roles.fieldRules(user);
8344
8485
  return {
8345
- update: new FieldAccessChecker("update", { user }, rules),
8346
- read: new FieldAccessChecker("read", { user }, rules)
8486
+ update: new FieldAccessChecker("update", { user, context }, rules),
8487
+ read: new FieldAccessChecker("read", { user, context }, rules)
8347
8488
  };
8348
8489
  }
8349
- async function may(cms, access, user, target, operation) {
8350
- return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
8490
+ async function may(cms, access, user, target, operation, context) {
8491
+ return await allowed(access, user, context) && await cms.roles.allows(user, target, operation) !== false;
8351
8492
  }
8352
- async function collection(cms, config, user, localized, roles) {
8493
+ async function collection(cms, config, user, localized, roles, context) {
8353
8494
  const target = { collection: config.slug };
8354
8495
  const fields = await serializeFields(
8355
8496
  config.fields,
8356
- await checkers(cms, user),
8497
+ await checkers(cms, user, context),
8357
8498
  localized,
8358
8499
  folderIds(cms)
8359
8500
  );
@@ -8372,11 +8513,11 @@ async function collection(cms, config, user, localized, roles) {
8372
8513
  schedule: config.schedule === true,
8373
8514
  fields,
8374
8515
  permissions: {
8375
- read: await may(cms, config.access?.read, user, target, "read"),
8376
- create: await may(cms, config.access?.create, user, target, "create"),
8377
- update: await may(cms, config.access?.update, user, target, "update"),
8378
- delete: await may(cms, config.access?.delete, user, target, "delete"),
8379
- publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
8516
+ read: await may(cms, config.access?.read, user, target, "read", context),
8517
+ create: await may(cms, config.access?.create, user, target, "create", context),
8518
+ update: await may(cms, config.access?.update, user, target, "update", context),
8519
+ delete: await may(cms, config.access?.delete, user, target, "delete", context),
8520
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish", context)
8380
8521
  }
8381
8522
  };
8382
8523
  if (config.labels) result.labels = config.labels;
@@ -8392,9 +8533,9 @@ async function collection(cms, config, user, localized, roles) {
8392
8533
  result.editIn = "drawer";
8393
8534
  return result;
8394
8535
  }
8395
- async function global(cms, config, user, localized) {
8536
+ async function global(cms, config, user, localized, context) {
8396
8537
  const target = { global: config.slug };
8397
- const checks = await checkers(cms, user);
8538
+ const checks = await checkers(cms, user, context);
8398
8539
  const result = {
8399
8540
  slug: config.slug,
8400
8541
  drafts: config.drafts === true,
@@ -8403,9 +8544,9 @@ async function global(cms, config, user, localized) {
8403
8544
  schedule: config.schedule === true,
8404
8545
  fields: await serializeFields(config.fields, checks, localized, folderIds(cms)),
8405
8546
  permissions: {
8406
- read: await may(cms, config.access?.read, user, target, "read"),
8407
- update: await may(cms, config.access?.update, user, target, "update"),
8408
- publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
8547
+ read: await may(cms, config.access?.read, user, target, "read", context),
8548
+ update: await may(cms, config.access?.update, user, target, "update", context),
8549
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish", context)
8409
8550
  }
8410
8551
  };
8411
8552
  if (config.label !== void 0) result.label = config.label;
@@ -8413,11 +8554,11 @@ async function global(cms, config, user, localized) {
8413
8554
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
8414
8555
  return result;
8415
8556
  }
8416
- async function adminSchema(cms, user, origin) {
8557
+ async function adminSchema(cms, user, origin, context = {}) {
8417
8558
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
8418
8559
  const localization = cms.config.localization;
8419
8560
  const localized = localization !== null;
8420
- const admin = user.role === "admin" && !user.apiKey;
8561
+ const admin = isSystemAdmin(user);
8421
8562
  const deliveries = cms.config.collections.some(
8422
8563
  (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
8423
8564
  );
@@ -8433,13 +8574,16 @@ async function adminSchema(cms, user, origin) {
8433
8574
  const roles = await cms.roles.options();
8434
8575
  return {
8435
8576
  locale: cms.config.admin.locale,
8577
+ switcher: cms.config.admin.switcher,
8436
8578
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
8437
8579
  menu: [...cms.config.admin.menu],
8438
8580
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
8439
8581
  collections: await Promise.all(
8440
- collections.map((c) => collection(cms, c, user, localized, roles))
8582
+ collections.map((c) => collection(cms, c, user, localized, roles, context))
8583
+ ),
8584
+ globals: await Promise.all(
8585
+ cms.config.globals.map((g) => global(cms, g, user, localized, context))
8441
8586
  ),
8442
- globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
8443
8587
  modules: adminModuleUrls(cms),
8444
8588
  uploadFromURL: cms.config.upload.fromURL !== void 0,
8445
8589
  upload: {
@@ -8504,7 +8648,7 @@ function folderIds(cms) {
8504
8648
  }
8505
8649
 
8506
8650
  // src/version.ts
8507
- var VERSION = true ? "0.42.0" : "0.0.0-dev";
8651
+ var VERSION = true ? "0.44.0" : "0.0.0-dev";
8508
8652
 
8509
8653
  // src/rest/admin-status.ts
8510
8654
  var DAY2 = 864e5;
@@ -8592,7 +8736,7 @@ function fieldAt(fields, path) {
8592
8736
  }
8593
8737
  return found;
8594
8738
  }
8595
- async function pickerFilter(cms, url, target, user) {
8739
+ async function pickerFilter(cms, url, target, user, context = {}) {
8596
8740
  const forCollection = url.searchParams.get("filterFor");
8597
8741
  const forGlobal = url.searchParams.get("filterForGlobal");
8598
8742
  const spec = forCollection ?? forGlobal;
@@ -8606,7 +8750,7 @@ async function pickerFilter(cms, url, target, user) {
8606
8750
  if (!field.filterOptions) return void 0;
8607
8751
  const rawId = url.searchParams.get("filterId");
8608
8752
  const id = rawId === null || rawId === "" ? void 0 : /^\d+$/.test(rawId) ? Number(rawId) : rawId;
8609
- const where = await field.filterOptions({ id, user, cms });
8753
+ const where = await field.filterOptions({ id, user, cms, context });
8610
8754
  return where === true ? void 0 : where;
8611
8755
  }
8612
8756
 
@@ -8766,10 +8910,19 @@ async function respond(cms, request, url, path, root, options, production) {
8766
8910
  if (path === null) throw new HttpError("Not found", 404);
8767
8911
  const segments = pathSegments(path);
8768
8912
  const { token, via } = readToken(request);
8769
- const user = token ? await cms.auth.verify(token) : null;
8770
- if (!user && via === "bearer" && token?.startsWith("ecms_"))
8913
+ const signedIn = token ? await cms.auth.verify(token) : null;
8914
+ if (!signedIn && via === "bearer" && token?.startsWith("ecms_"))
8771
8915
  throw new UnauthorizedError("Invalid or expired API key");
8772
- const ctx = { request, url, user, via: user ? via : null, token, headers };
8916
+ const { user, context } = await cms.applyOnRequest(request.headers, url, signedIn);
8917
+ const ctx = {
8918
+ request,
8919
+ url,
8920
+ user,
8921
+ context,
8922
+ via: signedIn ? via : null,
8923
+ token,
8924
+ headers
8925
+ };
8773
8926
  const method = request.method.toUpperCase();
8774
8927
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
8775
8928
  const audit = {
@@ -8833,6 +8986,7 @@ async function customEndpoint(cms, ctx, method, segments, root, options) {
8833
8986
  url: ctx.url,
8834
8987
  params,
8835
8988
  user: ctx.user,
8989
+ context: ctx.context,
8836
8990
  ip: options.getClientIp?.(ctx.request),
8837
8991
  cms,
8838
8992
  json: () => readJson(ctx.request)
@@ -8859,7 +9013,12 @@ function matchPath(path, segments) {
8859
9013
  }
8860
9014
  async function route(cms, ctx, method, segments, options) {
8861
9015
  const [first, second, third] = segments;
8862
- const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
9016
+ const access = {
9017
+ overrideAccess: false,
9018
+ user: ctx.user,
9019
+ context: ctx.context,
9020
+ ...parseLocale(ctx.url)
9021
+ };
8863
9022
  const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
8864
9023
  if (segments.length === 0) throw new HttpError("Not found", 404);
8865
9024
  if (first === "auth") return ssoRoute(cms, ctx, method, segments);
@@ -8957,14 +9116,14 @@ async function route(cms, ctx, method, segments, options) {
8957
9116
  const secret = cms.config.cronSecret ?? process.env.CRON_SECRET;
8958
9117
  const header = ctx.request.headers.get("authorization") ?? "";
8959
9118
  const byCron = !!secret && safeEqual(header, `Bearer ${secret}`);
8960
- if (!byCron && ctx.user?.role !== "admin") {
9119
+ if (!byCron && !isSystemAdmin(ctx.user)) {
8961
9120
  throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
8962
9121
  }
8963
9122
  return { body: await cms.runJobs() };
8964
9123
  }
8965
9124
  if (first === "admin" && second === "backups") {
8966
9125
  if (!ctx.user) throw new UnauthorizedError();
8967
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9126
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
8968
9127
  const [, , id2, action, extra] = segments;
8969
9128
  if (extra !== void 0) throw new HttpError("Not found", 404);
8970
9129
  if (id2 === void 0) {
@@ -9006,7 +9165,7 @@ async function route(cms, ctx, method, segments, options) {
9006
9165
  }
9007
9166
  if (first === "admin" && second === "email") {
9008
9167
  if (!ctx.user) throw new UnauthorizedError();
9009
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9168
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9010
9169
  const [, , action, extra] = segments;
9011
9170
  if (extra !== void 0) throw new HttpError("Not found", 404);
9012
9171
  if (action === void 0) {
@@ -9025,7 +9184,7 @@ async function route(cms, ctx, method, segments, options) {
9025
9184
  }
9026
9185
  if (first === "admin" && second === "sso" && third === void 0) {
9027
9186
  if (!ctx.user) throw new UnauthorizedError();
9028
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9187
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9029
9188
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9030
9189
  return { body: cms.auth.sso.settings(ctx.url.origin) };
9031
9190
  }
@@ -9066,7 +9225,7 @@ async function route(cms, ctx, method, segments, options) {
9066
9225
  }
9067
9226
  if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
9068
9227
  if (!ctx.user) throw new UnauthorizedError();
9069
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9228
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9070
9229
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9071
9230
  const parsed = parseId(third);
9072
9231
  if (parsed === void 0) throw new HttpError("Not found", 404);
@@ -9080,7 +9239,7 @@ async function route(cms, ctx, method, segments, options) {
9080
9239
  }
9081
9240
  if (first === "admin" && second === "roles") {
9082
9241
  if (!ctx.user) throw new UnauthorizedError();
9083
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9242
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9084
9243
  if (!cms.roles.enabled) throw new HttpError("Not found", 404);
9085
9244
  const [, , id2, action, extra] = segments;
9086
9245
  if (extra !== void 0) throw new HttpError("Not found", 404);
@@ -9166,7 +9325,7 @@ async function route(cms, ctx, method, segments, options) {
9166
9325
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9167
9326
  if (!ctx.user) throw new UnauthorizedError();
9168
9327
  if (second === "schema" && third === void 0)
9169
- return { body: await adminSchema(cms, ctx.user, ctx.url.origin) };
9328
+ return { body: await adminSchema(cms, ctx.user, ctx.url.origin, ctx.context) };
9170
9329
  if (second === "modules" && third !== void 0 && segments.length === 3) {
9171
9330
  const index = /^(\d+)\.js$/.exec(third)?.[1];
9172
9331
  const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
@@ -9186,13 +9345,19 @@ async function route(cms, ctx, method, segments, options) {
9186
9345
  return { body: await adminStatus(cms) };
9187
9346
  }
9188
9347
  if (second === "scheduled" && third === void 0)
9189
- return { body: await cms.upcomingJobs({ user: ctx.user, overrideAccess: false }) };
9348
+ return {
9349
+ body: await cms.upcomingJobs({
9350
+ user: ctx.user,
9351
+ context: ctx.context,
9352
+ overrideAccess: false
9353
+ })
9354
+ };
9190
9355
  const [, , collection3, id2, extra] = segments;
9191
9356
  if (second === "access" && collection3 && id2 && extra === void 0) {
9192
9357
  if (INTERNAL_COLLECTIONS.has(collection3) || !cms.config.collections.some((c) => c.slug === collection3)) {
9193
9358
  throw new HttpError(`Unknown collection "${collection3}"`, 404);
9194
9359
  }
9195
- return { body: await cms.documentPermissions(collection3, id2, ctx.user) };
9360
+ return { body: await cms.documentPermissions(collection3, id2, ctx.user, ctx.context) };
9196
9361
  }
9197
9362
  throw new HttpError("Not found", 404);
9198
9363
  }
@@ -9297,7 +9462,7 @@ async function route(cms, ctx, method, segments, options) {
9297
9462
  if (second === void 0) {
9298
9463
  if (method === "GET") {
9299
9464
  const query = parseListQuery(ctx.url);
9300
- const filter = await pickerFilter(cms, ctx.url, collection2, ctx.user);
9465
+ const filter = await pickerFilter(cms, ctx.url, collection2, ctx.user, ctx.context);
9301
9466
  if (filter) query.where = query.where ? { and: [query.where, filter] } : filter;
9302
9467
  return { body: await cms.find(collection2, { ...access, ...query, draft }) };
9303
9468
  }
@@ -9342,12 +9507,17 @@ async function route(cms, ctx, method, segments, options) {
9342
9507
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
9343
9508
  }
9344
9509
  async function documentAction(cms, ctx, method, collection2, id, path) {
9345
- const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
9510
+ const access = {
9511
+ overrideAccess: false,
9512
+ user: ctx.user,
9513
+ context: ctx.context,
9514
+ ...parseLocale(ctx.url)
9515
+ };
9346
9516
  const depth = parseDepth(ctx.url);
9347
9517
  const [action, versionId, extra] = path;
9348
9518
  if (collection2 === USERS && action === "password-link" && path.length === 1) {
9349
9519
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
9350
- if (ctx.user?.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
9520
+ if (!isSystemAdmin(ctx.user)) throw new ForbiddenError();
9351
9521
  const body = await readJson(ctx.request).catch(() => ({}));
9352
9522
  const sent = await cms.auth.sendPasswordLink(/^\d+$/.test(id) ? Number(id) : id, {
9353
9523
  origin: ctx.url.origin,
@@ -9400,7 +9570,12 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
9400
9570
  throw new HttpError("Not found", 404);
9401
9571
  }
9402
9572
  async function globalAction(cms, ctx, method, slug2, path) {
9403
- const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
9573
+ const access = {
9574
+ overrideAccess: false,
9575
+ user: ctx.user,
9576
+ context: ctx.context,
9577
+ ...parseLocale(ctx.url)
9578
+ };
9404
9579
  const depth = parseDepth(ctx.url);
9405
9580
  const [action, versionId, extra] = path;
9406
9581
  if (action === "versions" && versionId === void 0) {
@@ -9563,6 +9738,7 @@ async function servePrivateFile(cms, ctx, key2, head) {
9563
9738
  limit: 5,
9564
9739
  depth: 0,
9565
9740
  user: ctx.user,
9741
+ context: ctx.context,
9566
9742
  overrideAccess: false
9567
9743
  });
9568
9744
  const owns = found.docs.some(
@@ -9663,7 +9839,7 @@ async function ssoRoute(cms, ctx, method, segments) {
9663
9839
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9664
9840
  const asked = ctx.url.searchParams.get("user");
9665
9841
  const target = asked === null ? ctx.user.id : parseId(asked) ?? asked;
9666
- if (String(target) !== String(ctx.user.id) && (ctx.user.role !== "admin" || ctx.user.apiKey))
9842
+ if (String(target) !== String(ctx.user.id) && !isSystemAdmin(ctx.user))
9667
9843
  throw new ForbiddenError();
9668
9844
  return { body: await sso.identities(target) };
9669
9845
  }
@@ -10061,6 +10237,7 @@ export {
10061
10237
  isAdmin,
10062
10238
  isHasMany,
10063
10239
  isLoggedIn,
10240
+ isSystemAdmin,
10064
10241
  keyAllows,
10065
10242
  loadConfig,
10066
10243
  localStorage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",