@easy-cms/core 1.18.0 → 1.20.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/{chunk-NTAFZ4M6.js → chunk-QP6PENQI.js} +324 -25
- package/dist/{config-CpeC7EvC.d.ts → config-BuKlngv6.d.ts} +57 -7
- package/dist/{handler-BpKo6gh0.d.ts → handler-DuOjJu_X.d.ts} +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.js +1 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +1 -1
- package/dist/{plugin-_XG-lqA5.d.ts → plugin-zOdLJ1oh.d.ts} +7 -1
- package/dist/plugin.d.ts +2 -2
- package/package.json +1 -1
|
@@ -154,6 +154,7 @@ var ROLES = "user-roles";
|
|
|
154
154
|
var USER_IDENTITIES = "user-identities";
|
|
155
155
|
var AUDIT_LOGS = "audit-logs";
|
|
156
156
|
var OUTBOX = "outbox";
|
|
157
|
+
var RATE_LIMITS = "rate-limits";
|
|
157
158
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
158
159
|
SESSIONS,
|
|
159
160
|
LOGIN_ATTEMPTS,
|
|
@@ -166,7 +167,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
166
167
|
ROLES,
|
|
167
168
|
USER_IDENTITIES,
|
|
168
169
|
AUDIT_LOGS,
|
|
169
|
-
OUTBOX
|
|
170
|
+
OUTBOX,
|
|
171
|
+
RATE_LIMITS
|
|
170
172
|
]);
|
|
171
173
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
172
174
|
var CREATED_BY_FIELD = {
|
|
@@ -640,6 +642,17 @@ var outboxCollection = {
|
|
|
640
642
|
{ name: "error", type: "text" }
|
|
641
643
|
]
|
|
642
644
|
};
|
|
645
|
+
var rateLimitsCollection = {
|
|
646
|
+
slug: RATE_LIMITS,
|
|
647
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
648
|
+
fields: [
|
|
649
|
+
// `<anonymous|user|apiKey>:<id or HMAC of the IP>:<window number>`
|
|
650
|
+
{ name: "key", type: "text", required: true, unique: true },
|
|
651
|
+
{ name: "count", type: "number", required: true },
|
|
652
|
+
// When the window is over, for clearing old rows.
|
|
653
|
+
{ name: "expiresAt", type: "text", required: true, index: true }
|
|
654
|
+
]
|
|
655
|
+
};
|
|
643
656
|
var emailDeliveriesCollection = {
|
|
644
657
|
slug: EMAIL_DELIVERIES,
|
|
645
658
|
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
@@ -959,7 +972,10 @@ var AuditLog = class {
|
|
|
959
972
|
}
|
|
960
973
|
await auditContext.run({ via: "scheduler", user: null }, () => this.verify());
|
|
961
974
|
}
|
|
962
|
-
/**
|
|
975
|
+
/**
|
|
976
|
+
* What the dashboard warns about: many failed logins, sign-ins of break-glass accounts in the
|
|
977
|
+
* last day, entries not written, a failed check.
|
|
978
|
+
*/
|
|
963
979
|
async attention(now = /* @__PURE__ */ new Date()) {
|
|
964
980
|
if (!this.enabled) return [];
|
|
965
981
|
const out = [];
|
|
@@ -971,6 +987,20 @@ var AuditLog = class {
|
|
|
971
987
|
}
|
|
972
988
|
});
|
|
973
989
|
if (failed >= this.settings.failedLogins) out.push({ id: "failed-logins", count: failed });
|
|
990
|
+
const day = new Date(now.getTime() - 864e5).toISOString();
|
|
991
|
+
const breakGlass = await this.cms.db.find({
|
|
992
|
+
collection: AUDIT_LOGS,
|
|
993
|
+
where: { and: [{ action: { equals: "login.breakGlass" } }, { createdAt: { gte: day } }] },
|
|
994
|
+
sort: ["-id"],
|
|
995
|
+
limit: 0,
|
|
996
|
+
page: 1
|
|
997
|
+
});
|
|
998
|
+
if (breakGlass.docs.length > 0)
|
|
999
|
+
out.push({
|
|
1000
|
+
id: "break-glass",
|
|
1001
|
+
count: breakGlass.docs.length,
|
|
1002
|
+
emails: [...new Set(breakGlass.docs.map((row) => String(row.actorEmail ?? "")))]
|
|
1003
|
+
});
|
|
974
1004
|
if (this.failures > 0) out.push({ id: "audit-failures", count: this.failures });
|
|
975
1005
|
const last = await this.lastVerification();
|
|
976
1006
|
if (last && last.invalid > 0)
|
|
@@ -1381,6 +1411,9 @@ function unseal(secrets, value) {
|
|
|
1381
1411
|
return null;
|
|
1382
1412
|
}
|
|
1383
1413
|
}
|
|
1414
|
+
function passwordFor(policy) {
|
|
1415
|
+
return policy === true ? "everyone" : policy === false ? "admins" : "breakGlass";
|
|
1416
|
+
}
|
|
1384
1417
|
var SingleSignOn = class {
|
|
1385
1418
|
constructor(cms) {
|
|
1386
1419
|
this.cms = cms;
|
|
@@ -1399,9 +1432,19 @@ var SingleSignOn = class {
|
|
|
1399
1432
|
icon: p.icon ?? "key"
|
|
1400
1433
|
}));
|
|
1401
1434
|
}
|
|
1402
|
-
/**
|
|
1435
|
+
/**
|
|
1436
|
+
* Whether a user may sign in with a password: everyone, only admins (`auth.password: false`), or
|
|
1437
|
+
* only break-glass accounts (`{ breakGlass }`).
|
|
1438
|
+
*/
|
|
1403
1439
|
passwordAllowed(user) {
|
|
1404
|
-
|
|
1440
|
+
const policy = this.config.auth.password;
|
|
1441
|
+
if (policy === true) return true;
|
|
1442
|
+
if (policy === false) return user.role === "admin";
|
|
1443
|
+
return typeof user.email === "string" && policy.breakGlass.includes(user.email.toLowerCase());
|
|
1444
|
+
}
|
|
1445
|
+
/** Whether a user signs in with a password only as a break-glass account. */
|
|
1446
|
+
isBreakGlass(user) {
|
|
1447
|
+
return typeof this.config.auth.password === "object" && this.passwordAllowed(user);
|
|
1405
1448
|
}
|
|
1406
1449
|
/** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
|
|
1407
1450
|
callbackURL(provider, origin) {
|
|
@@ -1594,7 +1637,8 @@ var SingleSignOn = class {
|
|
|
1594
1637
|
callbackURL: this.callbackURL(p.id, origin),
|
|
1595
1638
|
details: { ...p.describe?.() ?? {} }
|
|
1596
1639
|
})),
|
|
1597
|
-
password: this.config.auth.password
|
|
1640
|
+
password: passwordFor(this.config.auth.password),
|
|
1641
|
+
...typeof this.config.auth.password === "object" ? { breakGlass: [...this.config.auth.password.breakGlass] } : {},
|
|
1598
1642
|
signUp: providerSignUp.domains.length > 0 ? {
|
|
1599
1643
|
domains: [...providerSignUp.domains],
|
|
1600
1644
|
role: providerSignUp.role ?? this.defaultRole()
|
|
@@ -1832,7 +1876,11 @@ var Auth = class {
|
|
|
1832
1876
|
}
|
|
1833
1877
|
await this.deleteExpiredSessions(user.id);
|
|
1834
1878
|
const session = await this.startSession(user);
|
|
1835
|
-
await this.cms.audit.record({
|
|
1879
|
+
await this.cms.audit.record({
|
|
1880
|
+
action: this.sso.isBreakGlass(user) ? "login.breakGlass" : "login",
|
|
1881
|
+
target: "auth",
|
|
1882
|
+
user: session.user
|
|
1883
|
+
});
|
|
1836
1884
|
return session;
|
|
1837
1885
|
}
|
|
1838
1886
|
/**
|
|
@@ -2006,6 +2054,58 @@ var Auth = class {
|
|
|
2006
2054
|
const path = target ?? `${this.config.admin.path}/${page === "verifyEmail" ? "verify-email" : "reset-password"}`;
|
|
2007
2055
|
return `${base}${path.startsWith("/") ? "" : "/"}${path}${path.includes("?") ? "&" : "?"}${query}`;
|
|
2008
2056
|
}
|
|
2057
|
+
/**
|
|
2058
|
+
* At start: when `auth.password` differs from the last start, records the change and ends the
|
|
2059
|
+
* sessions of accounts that may no longer use a password (single sign-on users sign in once
|
|
2060
|
+
* more). With break-glass accounts, warns when none of them can sign in with a password.
|
|
2061
|
+
* @internal
|
|
2062
|
+
*/
|
|
2063
|
+
async applyPasswordPolicy() {
|
|
2064
|
+
const policy = passwordPolicyName(this.config.auth.password);
|
|
2065
|
+
const stored = await this.db.findGlobal({ slug: PASSWORD_POLICY });
|
|
2066
|
+
const before = typeof stored?.policy === "string" ? stored.policy : "everyone";
|
|
2067
|
+
if (before !== policy) {
|
|
2068
|
+
let ended = 0;
|
|
2069
|
+
if (policy !== "everyone") {
|
|
2070
|
+
const users = /* @__PURE__ */ new Map();
|
|
2071
|
+
const { docs } = await this.db.find({ collection: SESSIONS, sort: [], limit: 0, page: 1 });
|
|
2072
|
+
for (const session of docs) {
|
|
2073
|
+
const id = String(session.user);
|
|
2074
|
+
if (!users.has(id))
|
|
2075
|
+
users.set(id, await this.db.findById({ collection: USERS, id: session.user }));
|
|
2076
|
+
const user = users.get(id);
|
|
2077
|
+
if (user && this.sso.passwordAllowed(user)) continue;
|
|
2078
|
+
await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
2079
|
+
ended++;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2083
|
+
await this.db.updateGlobal({ slug: PASSWORD_POLICY, data: { policy, updatedAt: at } });
|
|
2084
|
+
await this.cms.audit.record({
|
|
2085
|
+
action: "auth.password.policy",
|
|
2086
|
+
target: "auth",
|
|
2087
|
+
user: null,
|
|
2088
|
+
detail: { from: before, to: policy, sessionsEnded: ended }
|
|
2089
|
+
});
|
|
2090
|
+
this.cms.logger.info(
|
|
2091
|
+
`Password sign-in: ${before} \u2192 ${policy}${ended ? `; ${ended} session(s) ended` : ""}.`
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
const password = this.config.auth.password;
|
|
2095
|
+
if (typeof password === "object" && await this.hasUsers()) {
|
|
2096
|
+
const { docs } = await this.db.find({
|
|
2097
|
+
collection: USERS,
|
|
2098
|
+
where: { email: { in: [...password.breakGlass] } },
|
|
2099
|
+
sort: [],
|
|
2100
|
+
limit: 0,
|
|
2101
|
+
page: 1
|
|
2102
|
+
});
|
|
2103
|
+
if (!docs.some((user) => user.passwordHash && user.active !== false))
|
|
2104
|
+
this.cms.logger.warn(
|
|
2105
|
+
"auth.password.breakGlass: none of these accounts exists with a password, so nobody can sign in if the provider is down. Create one with `easy-cms admin:create`."
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2009
2109
|
async hasUsers() {
|
|
2010
2110
|
return await this.db.count({ collection: USERS }) > 0;
|
|
2011
2111
|
}
|
|
@@ -2375,6 +2475,12 @@ var Auth = class {
|
|
|
2375
2475
|
await this.db.delete({ collection: LOGIN_ATTEMPTS, id: attempt.id });
|
|
2376
2476
|
}
|
|
2377
2477
|
};
|
|
2478
|
+
var PASSWORD_POLICY = "easy-cms:password-policy";
|
|
2479
|
+
function passwordPolicyName(policy) {
|
|
2480
|
+
if (policy === true) return "everyone";
|
|
2481
|
+
if (policy === false) return "admins";
|
|
2482
|
+
return `breakGlass:${[...policy.breakGlass].sort().join(",")}`;
|
|
2483
|
+
}
|
|
2378
2484
|
|
|
2379
2485
|
// src/database.ts
|
|
2380
2486
|
var ADAPTER_API_VERSION = 1;
|
|
@@ -3977,7 +4083,7 @@ var VersionStore = class {
|
|
|
3977
4083
|
};
|
|
3978
4084
|
|
|
3979
4085
|
// src/local-api.ts
|
|
3980
|
-
import { createHash as createHash5, createHmac as
|
|
4086
|
+
import { createHash as createHash5, createHmac as createHmac8, randomBytes as randomBytes6, randomUUID as randomUUID3 } from "crypto";
|
|
3981
4087
|
|
|
3982
4088
|
// src/auth/common-passwords.ts
|
|
3983
4089
|
var LIST = `
|
|
@@ -4473,6 +4579,141 @@ function entryOf(row) {
|
|
|
4473
4579
|
};
|
|
4474
4580
|
}
|
|
4475
4581
|
|
|
4582
|
+
// src/rate-limit.ts
|
|
4583
|
+
import { createHmac as createHmac5 } from "crypto";
|
|
4584
|
+
var SYNC_MS = 5e3;
|
|
4585
|
+
var DEFAULTS = { window: 60, anonymous: 300, users: 1200, apiKeys: 1200 };
|
|
4586
|
+
var RateLimiter = class {
|
|
4587
|
+
constructor(db, secret, logger, settings) {
|
|
4588
|
+
this.db = db;
|
|
4589
|
+
this.secret = secret;
|
|
4590
|
+
this.logger = logger;
|
|
4591
|
+
this.settings = { ...DEFAULTS, ...settings };
|
|
4592
|
+
}
|
|
4593
|
+
db;
|
|
4594
|
+
secret;
|
|
4595
|
+
logger;
|
|
4596
|
+
counts = /* @__PURE__ */ new Map();
|
|
4597
|
+
settings;
|
|
4598
|
+
lastSync = Date.now();
|
|
4599
|
+
syncing;
|
|
4600
|
+
warnedNoIp = false;
|
|
4601
|
+
/**
|
|
4602
|
+
* Counts a request, or returns the seconds until the client may send another when it is over
|
|
4603
|
+
* its limit. Health checks and the cron's `jobs/run` aren't counted (the caller skips them).
|
|
4604
|
+
*/
|
|
4605
|
+
hit(user, ip, now = Date.now()) {
|
|
4606
|
+
const client = this.client(user, ip);
|
|
4607
|
+
if (!client) return void 0;
|
|
4608
|
+
const [kind, id, limit] = client;
|
|
4609
|
+
const windowMs = this.settings.window * 1e3;
|
|
4610
|
+
const bucket = Math.floor(now / windowMs);
|
|
4611
|
+
const key = `${kind}:${id}:${bucket}`;
|
|
4612
|
+
let count = this.counts.get(key);
|
|
4613
|
+
if (!count) {
|
|
4614
|
+
count = { total: 0, pending: 0, ends: (bucket + 1) * windowMs };
|
|
4615
|
+
this.counts.set(key, count);
|
|
4616
|
+
}
|
|
4617
|
+
if (now - this.lastSync >= SYNC_MS && !this.syncing) void this.sync(now);
|
|
4618
|
+
if (count.total + count.pending >= limit)
|
|
4619
|
+
return Math.max(1, Math.ceil((count.ends - now) / 1e3));
|
|
4620
|
+
count.pending++;
|
|
4621
|
+
return void 0;
|
|
4622
|
+
}
|
|
4623
|
+
/** Who is counted, and their limit; `undefined` when nothing limits them. */
|
|
4624
|
+
client(user, ip) {
|
|
4625
|
+
const { anonymous, users, apiKeys } = this.settings;
|
|
4626
|
+
const key = user?.apiKey;
|
|
4627
|
+
if (key) return apiKeys === false ? void 0 : ["apiKey", String(key.id), apiKeys];
|
|
4628
|
+
if (user) return users === false ? void 0 : ["user", String(user.id), users];
|
|
4629
|
+
if (anonymous === false) return void 0;
|
|
4630
|
+
if (!ip) {
|
|
4631
|
+
if (!this.warnedNoIp) {
|
|
4632
|
+
this.warnedNoIp = true;
|
|
4633
|
+
this.logger.warn(
|
|
4634
|
+
"rateLimit: the client IP is unknown, so visitors who are not signed in are not limited. Behind a proxy, set trustProxy on the adapter."
|
|
4635
|
+
);
|
|
4636
|
+
}
|
|
4637
|
+
return void 0;
|
|
4638
|
+
}
|
|
4639
|
+
const hashed = createHmac5("sha256", this.secret).update(`rate:${ip}`).digest("base64url");
|
|
4640
|
+
return ["anonymous", hashed.slice(0, 22), anonymous];
|
|
4641
|
+
}
|
|
4642
|
+
/**
|
|
4643
|
+
* Adds what this instance counted to the database and reads the totals back; forgets windows
|
|
4644
|
+
* that are over. Runs in the background every few seconds; `await` it in tests.
|
|
4645
|
+
*/
|
|
4646
|
+
sync(now = Date.now()) {
|
|
4647
|
+
this.lastSync = now;
|
|
4648
|
+
this.syncing ??= this.write(now).catch((error) => this.logger.warn(`rateLimit: counts not saved: ${String(error)}`)).finally(() => {
|
|
4649
|
+
this.syncing = void 0;
|
|
4650
|
+
});
|
|
4651
|
+
return this.syncing;
|
|
4652
|
+
}
|
|
4653
|
+
async write(now) {
|
|
4654
|
+
for (const [key, count] of this.counts) {
|
|
4655
|
+
if (count.ends <= now) {
|
|
4656
|
+
this.counts.delete(key);
|
|
4657
|
+
continue;
|
|
4658
|
+
}
|
|
4659
|
+
const added = count.pending;
|
|
4660
|
+
count.pending = 0;
|
|
4661
|
+
count.total = await this.add(key, count, added) ?? count.total + added;
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
/** Adds `by` to the key's row (made if missing) and returns the new total. */
|
|
4665
|
+
async add(key, count, by) {
|
|
4666
|
+
count.id ??= await this.rowId(key);
|
|
4667
|
+
if (count.id === void 0) {
|
|
4668
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
4669
|
+
try {
|
|
4670
|
+
const created = await this.db.create({
|
|
4671
|
+
collection: RATE_LIMITS,
|
|
4672
|
+
data: {
|
|
4673
|
+
key,
|
|
4674
|
+
count: by,
|
|
4675
|
+
expiresAt: new Date(count.ends).toISOString(),
|
|
4676
|
+
createdAt: stamp,
|
|
4677
|
+
updatedAt: stamp
|
|
4678
|
+
}
|
|
4679
|
+
});
|
|
4680
|
+
count.id = created.id;
|
|
4681
|
+
return by;
|
|
4682
|
+
} catch (error) {
|
|
4683
|
+
count.id = await this.rowId(key);
|
|
4684
|
+
if (count.id === void 0) throw error;
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
return this.db.increment({ collection: RATE_LIMITS, id: count.id, field: "count", by });
|
|
4688
|
+
}
|
|
4689
|
+
async rowId(key) {
|
|
4690
|
+
const { docs } = await this.db.find({
|
|
4691
|
+
collection: RATE_LIMITS,
|
|
4692
|
+
where: { key: { equals: key } },
|
|
4693
|
+
sort: [],
|
|
4694
|
+
limit: 1,
|
|
4695
|
+
page: 1
|
|
4696
|
+
});
|
|
4697
|
+
return docs[0]?.id;
|
|
4698
|
+
}
|
|
4699
|
+
/** Deletes the rows of windows that are over. From `runJobs`. */
|
|
4700
|
+
async clear(now = /* @__PURE__ */ new Date()) {
|
|
4701
|
+
let deleted = 0;
|
|
4702
|
+
for (; ; ) {
|
|
4703
|
+
const { docs } = await this.db.find({
|
|
4704
|
+
collection: RATE_LIMITS,
|
|
4705
|
+
where: { expiresAt: { lt: now.toISOString() } },
|
|
4706
|
+
sort: ["id"],
|
|
4707
|
+
limit: 500,
|
|
4708
|
+
page: 1
|
|
4709
|
+
});
|
|
4710
|
+
for (const row of docs) await this.db.delete({ collection: RATE_LIMITS, id: row.id });
|
|
4711
|
+
deleted += docs.length;
|
|
4712
|
+
if (docs.length < 500) return deleted;
|
|
4713
|
+
}
|
|
4714
|
+
}
|
|
4715
|
+
};
|
|
4716
|
+
|
|
4476
4717
|
// src/remote-file.ts
|
|
4477
4718
|
import { lookup as dnsLookup } from "dns";
|
|
4478
4719
|
import { request as httpRequest } from "http";
|
|
@@ -4715,7 +4956,7 @@ function read(response, maxBytes, deadline) {
|
|
|
4715
4956
|
var keys = (list) => new Set(list.split(/\s+/).filter(Boolean));
|
|
4716
4957
|
var CONFIG = keys(`secret previousSecrets db serverURL cors cronSecret timeZone webhooks events jobs localization routes admin
|
|
4717
4958
|
upload auth collections globals endpoints cliCommands onRequest fieldTypes apiKeys email backups
|
|
4718
|
-
audit logs countCacheSeconds maxPage httpCache outbox search telemetry plugins`);
|
|
4959
|
+
audit logs countCacheSeconds maxPage httpCache outbox rateLimit search telemetry plugins`);
|
|
4719
4960
|
var ADMIN = keys("path locale brand siteURL nav commands modules pages dashboard switcher");
|
|
4720
4961
|
var AUTH = keys(`roles rbac tokenExpiration maxLoginAttempts lockWindow trustedOrigins
|
|
4721
4962
|
resetPasswordExpiration inviteExpiration providers providerSignUp password setupCode members emails`);
|
|
@@ -4723,6 +4964,7 @@ var MEMBERS = keys("roles signUp pages emails");
|
|
|
4723
4964
|
var UPLOAD = keys("dir maxFileSize mimeTypes storage imageSizes fromURL folders privateStorage");
|
|
4724
4965
|
var BACKUPS = keys("frequency at keep dir storage sqlite encryptionKey");
|
|
4725
4966
|
var AUDIT = keys("keepDays values failedLogins scope");
|
|
4967
|
+
var RATE_LIMIT = keys("window anonymous users apiKeys");
|
|
4726
4968
|
var LOGS = keys("format requests");
|
|
4727
4969
|
var COLLECTION = keys(`slug labels fields useAsTitle drafts versions schedule preview access hooks
|
|
4728
4970
|
admin httpCache search`);
|
|
@@ -4848,6 +5090,7 @@ function checkConfigKeys(config, add) {
|
|
|
4848
5090
|
check2(c.upload, "upload", "upload", UPLOAD, add);
|
|
4849
5091
|
check2(c.backups, "backups", "backups", BACKUPS, add);
|
|
4850
5092
|
if (isObject(c.audit)) check2(c.audit, "audit", "audit", AUDIT, add);
|
|
5093
|
+
if (isObject(c.rateLimit)) check2(c.rateLimit, "rateLimit", "rateLimit", RATE_LIMIT, add);
|
|
4851
5094
|
check2(c.logs, "logs", "logs", LOGS, add);
|
|
4852
5095
|
for (const [kind, list] of [
|
|
4853
5096
|
["collections", c.collections],
|
|
@@ -5204,13 +5447,13 @@ var SearchIndex = class {
|
|
|
5204
5447
|
};
|
|
5205
5448
|
|
|
5206
5449
|
// src/webhooks.ts
|
|
5207
|
-
import { createHmac as
|
|
5450
|
+
import { createHmac as createHmac7, randomUUID as randomUUID2 } from "crypto";
|
|
5208
5451
|
|
|
5209
5452
|
// src/webhook-signature.ts
|
|
5210
|
-
import { createHmac as
|
|
5453
|
+
import { createHmac as createHmac6, timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
5211
5454
|
var TOLERANCE_SECONDS = 5 * 60;
|
|
5212
5455
|
function signWebhook(secret, delivery, timestamp, body) {
|
|
5213
|
-
return `v1=${
|
|
5456
|
+
return `v1=${createHmac6("sha256", secret).update(`${delivery}.${timestamp}.${body}`).digest("hex")}`;
|
|
5214
5457
|
}
|
|
5215
5458
|
function header(headers, name) {
|
|
5216
5459
|
if (headers instanceof Headers) return headers.get(name) ?? void 0;
|
|
@@ -5411,7 +5654,7 @@ var Webhooks = class {
|
|
|
5411
5654
|
const timestamp = Math.floor(Date.now() / 1e3);
|
|
5412
5655
|
headers["x-easy-cms-timestamp"] = String(timestamp);
|
|
5413
5656
|
headers["x-easy-cms-signature-v2"] = signWebhook(hook.secret, delivery, timestamp, body);
|
|
5414
|
-
headers["x-easy-cms-signature"] = `sha256=${
|
|
5657
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac7("sha256", hook.secret).update(body).digest("hex")}`;
|
|
5415
5658
|
}
|
|
5416
5659
|
try {
|
|
5417
5660
|
const response = await this.fetcher(hook.url, {
|
|
@@ -5810,8 +6053,16 @@ function validateAuth(config, add) {
|
|
|
5810
6053
|
if (auth === void 0) return;
|
|
5811
6054
|
if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
|
|
5812
6055
|
add("auth.rbac", "must be true or false");
|
|
5813
|
-
if (auth.password
|
|
5814
|
-
|
|
6056
|
+
if (typeof auth.password === "object" && auth.password !== null) {
|
|
6057
|
+
const { breakGlass } = auth.password;
|
|
6058
|
+
if (!Array.isArray(breakGlass) || breakGlass.length === 0 || breakGlass.some((email2) => typeof email2 !== "string" || !email2.includes("@")))
|
|
6059
|
+
add(
|
|
6060
|
+
"auth.password.breakGlass",
|
|
6061
|
+
"must list the emails of the accounts that may still use a password",
|
|
6062
|
+
"e.g. { breakGlass: ['emergency@example.com'] }"
|
|
6063
|
+
);
|
|
6064
|
+
} else if (auth.password !== void 0 && typeof auth.password !== "boolean")
|
|
6065
|
+
add("auth.password", "must be true, false or { breakGlass: [emails] }");
|
|
5815
6066
|
if (auth.setupCode !== void 0 && typeof auth.setupCode !== "string")
|
|
5816
6067
|
add("auth.setupCode", "must be a string");
|
|
5817
6068
|
const providers = auth.providers;
|
|
@@ -5862,8 +6113,12 @@ function validateAuth(config, add) {
|
|
|
5862
6113
|
if (!Array.isArray(providers) || providers.length === 0)
|
|
5863
6114
|
add("auth.providerSignUp", "needs auth.providers");
|
|
5864
6115
|
}
|
|
5865
|
-
if (auth.password
|
|
5866
|
-
add(
|
|
6116
|
+
if (auth.password !== void 0 && auth.password !== true && (!Array.isArray(providers) || providers.length === 0))
|
|
6117
|
+
add(
|
|
6118
|
+
"auth.password",
|
|
6119
|
+
"turning passwords off needs auth.providers",
|
|
6120
|
+
"otherwise nobody else could sign in"
|
|
6121
|
+
);
|
|
5867
6122
|
if (auth.roles !== void 0) {
|
|
5868
6123
|
const roles = auth.roles;
|
|
5869
6124
|
if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
|
|
@@ -7073,7 +7328,7 @@ async function resolveConfig(input) {
|
|
|
7073
7328
|
rbac: config.auth?.rbac === true,
|
|
7074
7329
|
providers: config.auth?.providers ?? [],
|
|
7075
7330
|
providerSignUp: config.auth?.providerSignUp ?? { domains: [] },
|
|
7076
|
-
password: config.auth?.password !== false,
|
|
7331
|
+
password: typeof config.auth?.password === "object" ? { breakGlass: config.auth.password.breakGlass.map((email2) => email2.toLowerCase()) } : config.auth?.password !== false,
|
|
7077
7332
|
setupCode: config.auth?.setupCode ?? process.env.EASY_CMS_SETUP_CODE ?? "",
|
|
7078
7333
|
tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
|
|
7079
7334
|
maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
|
|
@@ -7096,6 +7351,7 @@ async function resolveConfig(input) {
|
|
|
7096
7351
|
...config.email ? [emailDeliveriesCollection] : [],
|
|
7097
7352
|
...(config.jobs?.length ?? 0) > 0 ? [jobRunsCollection] : [],
|
|
7098
7353
|
...config.outbox ? [outboxCollection] : [],
|
|
7354
|
+
...config.rateLimit ? [rateLimitsCollection] : [],
|
|
7099
7355
|
databaseBackupsCollection,
|
|
7100
7356
|
...config.auth?.rbac ? [rolesCollection] : [],
|
|
7101
7357
|
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
|
|
@@ -7166,7 +7422,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
|
|
|
7166
7422
|
}
|
|
7167
7423
|
|
|
7168
7424
|
// src/version.ts
|
|
7169
|
-
var VERSION = true ? "1.
|
|
7425
|
+
var VERSION = true ? "1.20.0" : "0.0.0-dev";
|
|
7170
7426
|
|
|
7171
7427
|
// src/telemetry.ts
|
|
7172
7428
|
var DB_CALLS = /* @__PURE__ */ new Set([
|
|
@@ -7400,6 +7656,10 @@ async function createEasyCMS(config, options = {}) {
|
|
|
7400
7656
|
if (options.scheduler !== false) cms.startSearch();
|
|
7401
7657
|
const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0 || resolved3.outbox === true;
|
|
7402
7658
|
if (scheduling && options.scheduler !== false) cms.startScheduler();
|
|
7659
|
+
if (options.schema !== "skip")
|
|
7660
|
+
await cms.auth.applyPasswordPolicy().catch(
|
|
7661
|
+
(error) => logger.error(`Password sign-in policy: ${error.message}`, { error })
|
|
7662
|
+
);
|
|
7403
7663
|
if (process.env.NODE_ENV === "production" && !resolved3.auth.setupCode && await cms.auth.hasUsers().catch(() => true) === false)
|
|
7404
7664
|
logger.warn(
|
|
7405
7665
|
"No admin exists yet and no setup code is set: whoever opens the admin first becomes the admin. Set EASY_CMS_SETUP_CODE, or create the admin with `easy-cms admin:create`."
|
|
@@ -7444,6 +7704,8 @@ var EasyCMS = class {
|
|
|
7444
7704
|
mailer;
|
|
7445
7705
|
/** With `outbox: true`: runs after hooks and events from rows saves write. */
|
|
7446
7706
|
outbox;
|
|
7707
|
+
/** @internal Requests per client (`rateLimit`), for the REST API. */
|
|
7708
|
+
rateLimiter;
|
|
7447
7709
|
/** With `search`: keeps the index in step with published documents. */
|
|
7448
7710
|
searchIndex;
|
|
7449
7711
|
/** @internal With `telemetry`: OpenTelemetry spans and metrics (set by `createEasyCMS`). */
|
|
@@ -7484,6 +7746,7 @@ var EasyCMS = class {
|
|
|
7484
7746
|
() => this.runOutbox(entry, done, mark)
|
|
7485
7747
|
) : this.runOutbox(entry, done, mark)
|
|
7486
7748
|
) : void 0;
|
|
7749
|
+
this.rateLimiter = config.rateLimit ? new RateLimiter(db, config.secret, logger, config.rateLimit) : void 0;
|
|
7487
7750
|
this.searchIndex = config.search ? new SearchIndex(
|
|
7488
7751
|
config.search,
|
|
7489
7752
|
config.collections,
|
|
@@ -7830,7 +8093,7 @@ var EasyCMS = class {
|
|
|
7830
8093
|
/** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
|
|
7831
8094
|
sign(purpose, value) {
|
|
7832
8095
|
const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
7833
|
-
const mac =
|
|
8096
|
+
const mac = createHmac8("sha256", this.config.secret).update(`${purpose}:${payload}`);
|
|
7834
8097
|
return `${payload}.${mac.digest("base64url")}`;
|
|
7835
8098
|
}
|
|
7836
8099
|
/** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
|
|
@@ -7957,7 +8220,7 @@ var EasyCMS = class {
|
|
|
7957
8220
|
return signedWith(secretsOf(this.config), `media:${key}:${at}`, signature);
|
|
7958
8221
|
}
|
|
7959
8222
|
mediaSignature(key, expires) {
|
|
7960
|
-
return
|
|
8223
|
+
return createHmac8("sha256", this.config.secret).update(`media:${key}:${expires}`).digest("base64url");
|
|
7961
8224
|
}
|
|
7962
8225
|
/**
|
|
7963
8226
|
* How many documents use any of these files in a top-level upload field (rich text and fields
|
|
@@ -8402,7 +8665,10 @@ var EasyCMS = class {
|
|
|
8402
8665
|
return out2;
|
|
8403
8666
|
}
|
|
8404
8667
|
if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
|
|
8405
|
-
if (password !== void 0)
|
|
8668
|
+
if (password !== void 0) {
|
|
8669
|
+
await this.guardPassword({ ...existing, ...prepared });
|
|
8670
|
+
prepared.passwordHash = await hashPassword(password);
|
|
8671
|
+
}
|
|
8406
8672
|
if (collection2 === MEDIA_FOLDERS) {
|
|
8407
8673
|
await this.validateFolder(prepared, parsed);
|
|
8408
8674
|
if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
|
|
@@ -8974,6 +9240,7 @@ var EasyCMS = class {
|
|
|
8974
9240
|
(error) => this.logger.error(`Backups: ${error.message}`, { error })
|
|
8975
9241
|
);
|
|
8976
9242
|
await this.audit.upkeep(now).catch((error) => this.logger.error(`Audit log: ${error.message}`, { error }));
|
|
9243
|
+
await this.rateLimiter?.clear(now).catch((error) => this.logger.error(`Rate limits: ${error.message}`, { error }));
|
|
8977
9244
|
const result = {
|
|
8978
9245
|
scheduled,
|
|
8979
9246
|
webhooks: await this.retryWebhooks(now),
|
|
@@ -9740,7 +10007,10 @@ var EasyCMS = class {
|
|
|
9740
10007
|
{ ...base, operation: "create" },
|
|
9741
10008
|
prepared
|
|
9742
10009
|
);
|
|
9743
|
-
if (password !== void 0)
|
|
10010
|
+
if (password !== void 0) {
|
|
10011
|
+
await this.guardPassword(prepared);
|
|
10012
|
+
prepared.passwordHash = await hashPassword(password);
|
|
10013
|
+
}
|
|
9744
10014
|
if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
|
|
9745
10015
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
9746
10016
|
const row = { ...prepared, createdAt: now, updatedAt: now };
|
|
@@ -10060,6 +10330,20 @@ var EasyCMS = class {
|
|
|
10060
10330
|
return clean2;
|
|
10061
10331
|
}
|
|
10062
10332
|
/** Refuses changes that would leave no active admin (and lock everyone out). */
|
|
10333
|
+
/**
|
|
10334
|
+
* With break-glass accounts (`auth.password: { breakGlass }`), other accounts get no password:
|
|
10335
|
+
* they couldn't use it. The first account, made before anyone can sign in with a provider, may.
|
|
10336
|
+
*/
|
|
10337
|
+
async guardPassword(user) {
|
|
10338
|
+
if (typeof this.config.auth.password !== "object" || this.auth.sso.passwordAllowed(user)) return;
|
|
10339
|
+
if (!await this.auth.hasUsers()) return;
|
|
10340
|
+
throw new ValidationError(USERS, [
|
|
10341
|
+
{
|
|
10342
|
+
field: "password",
|
|
10343
|
+
message: "passwords are off: this account signs in with single sign-on"
|
|
10344
|
+
}
|
|
10345
|
+
]);
|
|
10346
|
+
}
|
|
10063
10347
|
async guardLastAdmin(id, before, after) {
|
|
10064
10348
|
const wasAdmin = before.role === "admin" && before.active !== false;
|
|
10065
10349
|
const staysAdmin = after.role === "admin" && after.active !== false;
|
|
@@ -11622,6 +11906,8 @@ async function adminSchema(cms, user, origin, context = {}) {
|
|
|
11622
11906
|
locale: cms.config.admin.locale,
|
|
11623
11907
|
switcher: cms.config.admin.switcher || null,
|
|
11624
11908
|
passwordLinks: cms.auth.canSendPasswordLinks(origin),
|
|
11909
|
+
passwordFor: passwordFor(cms.config.auth.password),
|
|
11910
|
+
...typeof cms.config.auth.password === "object" && isSystemAdmin(user) ? { breakGlass: [...cms.config.auth.password.breakGlass] } : {},
|
|
11625
11911
|
nav: buildNav(cms, adminCollections, adminGlobals, adminPages, views),
|
|
11626
11912
|
commands: cms.config.admin.commands.map((c) => JSON.parse(JSON.stringify(c))),
|
|
11627
11913
|
localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
|
|
@@ -12242,6 +12528,10 @@ async function respondNow(cms, request, url, path, root, options, production, re
|
|
|
12242
12528
|
trustProxy: options.trustProxy === true
|
|
12243
12529
|
};
|
|
12244
12530
|
const method = request.method.toUpperCase();
|
|
12531
|
+
const isJobsRun = !root && segments[0] === "jobs" && segments[1] === "run";
|
|
12532
|
+
const wait = isJobsRun ? void 0 : cms.rateLimiter?.hit(user, options.getClientIp?.(request));
|
|
12533
|
+
if (wait !== void 0)
|
|
12534
|
+
throw new TooManyRequestsError("Too many requests: try again later", wait);
|
|
12245
12535
|
if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
|
|
12246
12536
|
const audit = {
|
|
12247
12537
|
user,
|
|
@@ -12434,7 +12724,14 @@ async function route(cms, ctx, method, path, options) {
|
|
|
12434
12724
|
const csrfToken2 = ctx.via === "cookie" && ctx.token ? cms.auth.csrfFor(ctx.token) : void 0;
|
|
12435
12725
|
if (csrfToken2)
|
|
12436
12726
|
ctx.headers.append("set-cookie", cookie(ctx, CSRF_COOKIE, csrfToken2, { httpOnly: false }));
|
|
12437
|
-
return {
|
|
12727
|
+
return {
|
|
12728
|
+
body: {
|
|
12729
|
+
user: ctx.user,
|
|
12730
|
+
// Whether this user may set and use a password (`auth.password`).
|
|
12731
|
+
...ctx.user ? { password: cms.auth.sso.passwordAllowed(ctx.user) } : {},
|
|
12732
|
+
...csrfToken2 ? { csrfToken: csrfToken2 } : {}
|
|
12733
|
+
}
|
|
12734
|
+
};
|
|
12438
12735
|
}
|
|
12439
12736
|
case "GET init":
|
|
12440
12737
|
return {
|
|
@@ -12442,9 +12739,11 @@ async function route(cms, ctx, method, path, options) {
|
|
|
12442
12739
|
hasUsers: await cms.auth.hasUsers(),
|
|
12443
12740
|
// The login page offers "Forgot password?" only when the email can be sent.
|
|
12444
12741
|
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
|
|
12445
|
-
// Buttons to sign in with outside accounts; `password: false`: only
|
|
12742
|
+
// Buttons to sign in with outside accounts; `password: false`: only some use one
|
|
12743
|
+
// (`passwordFor`: admins, or break-glass accounts), behind a folded form.
|
|
12446
12744
|
providers: cms.auth.sso.providers(),
|
|
12447
|
-
password: cms.config.auth.password,
|
|
12745
|
+
password: cms.config.auth.password === true,
|
|
12746
|
+
passwordFor: passwordFor(cms.config.auth.password),
|
|
12448
12747
|
// The first admin needs the setup code (`EASY_CMS_SETUP_CODE`).
|
|
12449
12748
|
setupCode: cms.config.auth.setupCode !== ""
|
|
12450
12749
|
}
|
|
@@ -432,8 +432,10 @@ interface AdminSso {
|
|
|
432
432
|
callbackURL: string | null;
|
|
433
433
|
details: Record<string, string>;
|
|
434
434
|
})[];
|
|
435
|
-
/** Who may sign in with a password: everyone, or only
|
|
436
|
-
password: 'everyone' | 'admins';
|
|
435
|
+
/** Who may sign in with a password: everyone, only admins, or only break-glass accounts. */
|
|
436
|
+
password: 'everyone' | 'admins' | 'breakGlass';
|
|
437
|
+
/** The accounts that may still use a password (`auth.password: { breakGlass }`). */
|
|
438
|
+
breakGlass?: string[];
|
|
437
439
|
/** Email domains whose people get an account when they first sign in. */
|
|
438
440
|
signUp: {
|
|
439
441
|
domains: string[];
|
|
@@ -461,10 +463,17 @@ declare class SingleSignOn {
|
|
|
461
463
|
private get config();
|
|
462
464
|
get enabled(): boolean;
|
|
463
465
|
providers(): SsoProviderRef[];
|
|
464
|
-
/**
|
|
466
|
+
/**
|
|
467
|
+
* Whether a user may sign in with a password: everyone, only admins (`auth.password: false`), or
|
|
468
|
+
* only break-glass accounts (`{ breakGlass }`).
|
|
469
|
+
*/
|
|
465
470
|
passwordAllowed(user: {
|
|
466
471
|
readonly [key: string]: unknown;
|
|
467
472
|
}): boolean;
|
|
473
|
+
/** Whether a user signs in with a password only as a break-glass account. */
|
|
474
|
+
isBreakGlass(user: {
|
|
475
|
+
readonly [key: string]: unknown;
|
|
476
|
+
}): boolean;
|
|
468
477
|
/** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
|
|
469
478
|
callbackURL(provider: string, origin?: string): string | null;
|
|
470
479
|
/**
|
|
@@ -2331,6 +2340,11 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
2331
2340
|
/** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
|
|
2332
2341
|
private prepare;
|
|
2333
2342
|
/** Refuses changes that would leave no active admin (and lock everyone out). */
|
|
2343
|
+
/**
|
|
2344
|
+
* With break-glass accounts (`auth.password: { breakGlass }`), other accounts get no password:
|
|
2345
|
+
* they couldn't use it. The first account, made before anyone can sign in with a provider, may.
|
|
2346
|
+
*/
|
|
2347
|
+
private guardPassword;
|
|
2334
2348
|
private guardLastAdmin;
|
|
2335
2349
|
private makeSlugsUnique;
|
|
2336
2350
|
private checkUnique;
|
|
@@ -2484,10 +2498,17 @@ declare class AuditLog {
|
|
|
2484
2498
|
resigned: number;
|
|
2485
2499
|
invalid: ID[];
|
|
2486
2500
|
}>;
|
|
2487
|
-
/**
|
|
2501
|
+
/**
|
|
2502
|
+
* What the dashboard warns about: many failed logins, sign-ins of break-glass accounts in the
|
|
2503
|
+
* last day, entries not written, a failed check.
|
|
2504
|
+
*/
|
|
2488
2505
|
attention(now?: Date): Promise<({
|
|
2489
2506
|
id: 'failed-logins';
|
|
2490
2507
|
count: number;
|
|
2508
|
+
} | {
|
|
2509
|
+
id: 'break-glass';
|
|
2510
|
+
count: number;
|
|
2511
|
+
emails: string[];
|
|
2491
2512
|
} | {
|
|
2492
2513
|
id: 'audit-failures';
|
|
2493
2514
|
count: number;
|
|
@@ -3344,10 +3365,13 @@ interface AuthConfig {
|
|
|
3344
3365
|
readonly role?: string;
|
|
3345
3366
|
};
|
|
3346
3367
|
/**
|
|
3347
|
-
*
|
|
3348
|
-
* everyone
|
|
3368
|
+
* Who may sign in with a password; everyone else signs in with `providers`. Default `true`:
|
|
3369
|
+
* everyone. `false`: only admins (a way in when the provider is down). `{ breakGlass: [emails] }`:
|
|
3370
|
+
* only those accounts, for organisations that require single sign-on (and its MFA) for every
|
|
3371
|
+
* other account, admins included. A change ends the sessions of accounts that may no longer use a
|
|
3372
|
+
* password, and is recorded in the audit log, as is each sign-in of a break-glass account.
|
|
3349
3373
|
*/
|
|
3350
|
-
readonly password?: boolean;
|
|
3374
|
+
readonly password?: boolean | PasswordPolicy;
|
|
3351
3375
|
/**
|
|
3352
3376
|
* A code the first admin must enter to create their account, so nobody else can claim a
|
|
3353
3377
|
* freshly deployed site. Default: the `EASY_CMS_SETUP_CODE` environment variable; without it,
|
|
@@ -3366,6 +3390,25 @@ interface AuthConfig {
|
|
|
3366
3390
|
readonly passwordChanged?: PasswordEmailFn;
|
|
3367
3391
|
};
|
|
3368
3392
|
}
|
|
3393
|
+
/** `auth.password` with break-glass accounts: the only ones that sign in with a password. */
|
|
3394
|
+
interface PasswordPolicy {
|
|
3395
|
+
/** Emails of the accounts that may still use a password, e.g. one kept for when the provider is down. */
|
|
3396
|
+
readonly breakGlass: readonly string[];
|
|
3397
|
+
}
|
|
3398
|
+
interface RateLimitConfig {
|
|
3399
|
+
/** Seconds each count lasts. Default 60. */
|
|
3400
|
+
readonly window?: number;
|
|
3401
|
+
/**
|
|
3402
|
+
* Requests per window from one IP without signing in. Default 300; `false`: no limit. Needs the
|
|
3403
|
+
* client's IP: behind a proxy, set `trustProxy` on the adapter (without it, visitors aren't
|
|
3404
|
+
* limited, and the log says so once).
|
|
3405
|
+
*/
|
|
3406
|
+
readonly anonymous?: number | false;
|
|
3407
|
+
/** Requests per window from one signed-in user (the admin included). Default 1200; `false`: no limit. */
|
|
3408
|
+
readonly users?: number | false;
|
|
3409
|
+
/** Requests per window with one API key. Default 1200; `false`: no limit. */
|
|
3410
|
+
readonly apiKeys?: number | false;
|
|
3411
|
+
}
|
|
3369
3412
|
/** What `onRequest` receives. */
|
|
3370
3413
|
interface OnRequestArgs {
|
|
3371
3414
|
readonly headers: Headers;
|
|
@@ -3673,6 +3716,13 @@ interface Config {
|
|
|
3673
3716
|
* Default `false`: hooks run in the request, after the save.
|
|
3674
3717
|
*/
|
|
3675
3718
|
readonly outbox?: boolean;
|
|
3719
|
+
/**
|
|
3720
|
+
* Limits requests to the API per client and minute, counted across instances: each visitor's
|
|
3721
|
+
* IP, each signed-in user and each API key. Over the limit, the API answers `429` with
|
|
3722
|
+
* `Retry-After`. `{}` uses the defaults. Needs a migration for the `rate-limits` table. Default
|
|
3723
|
+
* off.
|
|
3724
|
+
*/
|
|
3725
|
+
readonly rateLimit?: RateLimitConfig;
|
|
3676
3726
|
/**
|
|
3677
3727
|
* Relevance-ranked search of the collections that have `search`, e.g. `memorySearch()` for
|
|
3678
3728
|
* development and small sites, or an adapter for a search engine. Published documents are
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { I as ID, C as Config, E as EasyCMS, L as LocalizationConfig, F as Field, a as FieldError, b as FilterOptions, c as CreateEasyCMSOptions, R as ResolvedConfig } from './config-
|
|
1
|
+
import { I as ID, C as Config, E as EasyCMS, L as LocalizationConfig, F as Field, a as FieldError, b as FilterOptions, c as CreateEasyCMSOptions, R as ResolvedConfig } from './config-BuKlngv6.js';
|
|
2
2
|
|
|
3
3
|
/** Decrypts a file from `encryptBackup`; throws for another key or a changed file. */
|
|
4
4
|
declare function decryptBackup(data: Uint8Array, encryptionKey: string): Uint8Array;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { P as PaginatedDocs, I as ID, d as EmailSetting, W as WebhookPayload } from './config-
|
|
2
|
-
export { A as ADAPTER_API_VERSION, e as ADMIN_ICONS, f as Access, g as AccessArgs, h as AccessOptions, i as AdminBrand, j as AdminCommand, k as AdminComponent, l as AdminConfig, m as AdminFieldArgs, n as AdminIcon, o as AdminLocale, p as AdminPage, q as AdminRole, r as AdminRoleChange, s as AdminRoleField, t as AdminRoleTarget, u as AdminRoleView, v as AdminRoles, w as AdminSso, x as AdminSwitcher, y as AdminViewAccess, z as AfterChangeHook, B as AfterDeleteHook, D as AfterReadHook, G as ApiKeyContext, H as ApiKeyGlobalOperation, J as ApiKeyOperation, K as ApiKeyPermissions, M as ArrayField, N as AuditChange, O as AuditConfig, Q as AuditEntry, S as AuditLog, T as AuditPage, U as AuditVerification, V as Auth, X as AuthConfig, Y as AuthProvider, Z as AuthProviderProfile, _ as AuthProviderRequest, $ as AuthUser, a0 as BackupsConfig, a1 as BeforeChangeHook, a2 as BeforeDeleteHook, a3 as BeforeValidateHook, a4 as Block, a5 as BlocksField, a6 as BooleanField, a7 as BuiltinApiKeysCollection, a8 as BuiltinMediaCollection, a9 as BuiltinMediaFoldersCollection, aa as BuiltinUsersCollection, ab as BundleHints, ac as CliCommand, ad as CollectionAccess, ae as CollectionAdmin, af as CollectionConfig, ag as CollectionDocument, ah as CollectionHooks, ai as CollectionListAdmin, aj as CollectionSearch, ak as CollectionSlug, C as Config, al as ConfigError, am as ConfigIssue, an as ContainerAdmin, c as CreateEasyCMSOptions, ao as CreateInput, ap as CreateMigrationResult, aq as CursorDocs, ar as CustomField, as as CustomFieldTypes, at as DashboardWidget, au as Database, av as DatabaseAdapter, aw as DatabaseInitArgs, ax as DatabaseTransfer, ay as DateField, az as DepthOptions, aA as DirectUpload, aB as DiskStorageOptions, aC as DocumentOf, E as EasyCMS, aD as EasyCMSError, aE as EmailAdapter, aF as EmailField, aG as EmailMessage, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, aK as FIELD_TYPES, F as Field, aL as FieldAccess, aM as FieldAccessFn, aN as FieldAdmin, a as FieldError, aO as FieldRule, aP as FieldType, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, aT as FieldValidate, aU as FieldValidateContext, aV as FieldValue, aW as FieldsInput, aX as FieldsValue, b as FilterOptions, aY as FilterOptionsArgs, aZ as FindAfterArgs, a_ as FindArgs, a$ as FindOptions, b0 as FolderLevel, b1 as FolderPermissions, b2 as ForbiddenError, b3 as GlobalAccess, b4 as GlobalConfig, b5 as GlobalDocument, b6 as GlobalHooks, b7 as GlobalInput, b8 as GlobalSlug, b9 as GroupField, ba as ImageSize, bb as IncrementOptions, bc as InferCollection, bd as InferGlobal, be as JobConfig, bf as JsonField, bg as KeysetPage, bh as Label, bi as LayoutNode, bj as LivePreview, bk as LocaleOf, L as LocalizationConfig, bl as LogDetails, bm as Logger, bn as LoginArgs, bo as LogsConfig, bp as MediaDocument, bq as MediaSize, br as MembersConfig, bs as MembersSignup, bt as MigrationInfo, bu as NavGroup, bv as NotFoundError, bw as NumberField, bx as OnRequest, by as OnRequestArgs, bz as Operation, bA as OutboxRow, bB as PLUGIN_API_VERSION, bC as PasswordEmail, bD as PasswordEmailArgs, bE as PasswordEmailFn, bF as PasswordLinkOptions, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bK as PreviewTarget, bL as PreviewURL, bM as QueryError, bN as ROLE_OPERATIONS, bO as RawDocument, bP as ReadOptions, bQ as RelationshipField, bR as RequestContext, R as ResolvedConfig, bS as RichTextDocument, bT as RichTextField, bU as Role, bV as RoleOperation, bW as RolePermissions, bX as Roles, bY as RoutesConfig, bZ as ScheduledPublish, b_ as SchemaError, b$ as SchemaMode, c0 as SearchAdapter, c1 as SearchEntry, c2 as SearchHits, c3 as SearchOptions, c4 as SearchQuery, c5 as SearchResults, c6 as SearchSetting, c7 as SelectField, c8 as SelectOption, c9 as Session, ca as SidebarPanel, cb as SignupArgs, cc as SignupResult, cd as SingleSignOn, ce as SlugField, cf as SlugOf, cg as SsoOutcome, ch as SsoProviderRef, ci as StorageAdapter, cj as StoredFile, ck as TRIGRAM_LIKE_MIN, cl as TelemetryConfig, cm as TextField, cn as TextareaField, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cr as UncountedDocs, cs as UpdateInput, ct as UpdateOptions, cu as UploadConfig, cv as UploadField, cw as UploadFromURLConfig, cx as UserIdentity, cy as ValidationError, cz as Version, cA as VersionSummary, cB as VersionsConfig, cC as WEBHOOK_EVENTS, cD as WebhookConfig, cE as WebhookEvent, cF as Where, cG as WhereOperators, cH as anyone, cI as consoleEmail, cJ as consoleLogger, cK as createEasyCMS, cL as defineConfig, cM as defineFieldType, cN as definePlugin, cO as diskStorage, cP as hasRows, cQ as isAdmin, cR as isHasMany, cS as isSignedIn, cT as isStaff, cU as isSystemAdmin, cV as jsonLogger, cW as memorySearch, cX as mimeAllowedBy, cY as rowFields, cZ as silentLogger, c_ as uniqueWithinOf } from './config-
|
|
3
|
-
export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-
|
|
4
|
-
export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-
|
|
1
|
+
import { P as PaginatedDocs, I as ID, d as EmailSetting, W as WebhookPayload } from './config-BuKlngv6.js';
|
|
2
|
+
export { A as ADAPTER_API_VERSION, e as ADMIN_ICONS, f as Access, g as AccessArgs, h as AccessOptions, i as AdminBrand, j as AdminCommand, k as AdminComponent, l as AdminConfig, m as AdminFieldArgs, n as AdminIcon, o as AdminLocale, p as AdminPage, q as AdminRole, r as AdminRoleChange, s as AdminRoleField, t as AdminRoleTarget, u as AdminRoleView, v as AdminRoles, w as AdminSso, x as AdminSwitcher, y as AdminViewAccess, z as AfterChangeHook, B as AfterDeleteHook, D as AfterReadHook, G as ApiKeyContext, H as ApiKeyGlobalOperation, J as ApiKeyOperation, K as ApiKeyPermissions, M as ArrayField, N as AuditChange, O as AuditConfig, Q as AuditEntry, S as AuditLog, T as AuditPage, U as AuditVerification, V as Auth, X as AuthConfig, Y as AuthProvider, Z as AuthProviderProfile, _ as AuthProviderRequest, $ as AuthUser, a0 as BackupsConfig, a1 as BeforeChangeHook, a2 as BeforeDeleteHook, a3 as BeforeValidateHook, a4 as Block, a5 as BlocksField, a6 as BooleanField, a7 as BuiltinApiKeysCollection, a8 as BuiltinMediaCollection, a9 as BuiltinMediaFoldersCollection, aa as BuiltinUsersCollection, ab as BundleHints, ac as CliCommand, ad as CollectionAccess, ae as CollectionAdmin, af as CollectionConfig, ag as CollectionDocument, ah as CollectionHooks, ai as CollectionListAdmin, aj as CollectionSearch, ak as CollectionSlug, C as Config, al as ConfigError, am as ConfigIssue, an as ContainerAdmin, c as CreateEasyCMSOptions, ao as CreateInput, ap as CreateMigrationResult, aq as CursorDocs, ar as CustomField, as as CustomFieldTypes, at as DashboardWidget, au as Database, av as DatabaseAdapter, aw as DatabaseInitArgs, ax as DatabaseTransfer, ay as DateField, az as DepthOptions, aA as DirectUpload, aB as DiskStorageOptions, aC as DocumentOf, E as EasyCMS, aD as EasyCMSError, aE as EmailAdapter, aF as EmailField, aG as EmailMessage, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, aK as FIELD_TYPES, F as Field, aL as FieldAccess, aM as FieldAccessFn, aN as FieldAdmin, a as FieldError, aO as FieldRule, aP as FieldType, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, aT as FieldValidate, aU as FieldValidateContext, aV as FieldValue, aW as FieldsInput, aX as FieldsValue, b as FilterOptions, aY as FilterOptionsArgs, aZ as FindAfterArgs, a_ as FindArgs, a$ as FindOptions, b0 as FolderLevel, b1 as FolderPermissions, b2 as ForbiddenError, b3 as GlobalAccess, b4 as GlobalConfig, b5 as GlobalDocument, b6 as GlobalHooks, b7 as GlobalInput, b8 as GlobalSlug, b9 as GroupField, ba as ImageSize, bb as IncrementOptions, bc as InferCollection, bd as InferGlobal, be as JobConfig, bf as JsonField, bg as KeysetPage, bh as Label, bi as LayoutNode, bj as LivePreview, bk as LocaleOf, L as LocalizationConfig, bl as LogDetails, bm as Logger, bn as LoginArgs, bo as LogsConfig, bp as MediaDocument, bq as MediaSize, br as MembersConfig, bs as MembersSignup, bt as MigrationInfo, bu as NavGroup, bv as NotFoundError, bw as NumberField, bx as OnRequest, by as OnRequestArgs, bz as Operation, bA as OutboxRow, bB as PLUGIN_API_VERSION, bC as PasswordEmail, bD as PasswordEmailArgs, bE as PasswordEmailFn, bF as PasswordLinkOptions, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bK as PreviewTarget, bL as PreviewURL, bM as QueryError, bN as ROLE_OPERATIONS, bO as RawDocument, bP as ReadOptions, bQ as RelationshipField, bR as RequestContext, R as ResolvedConfig, bS as RichTextDocument, bT as RichTextField, bU as Role, bV as RoleOperation, bW as RolePermissions, bX as Roles, bY as RoutesConfig, bZ as ScheduledPublish, b_ as SchemaError, b$ as SchemaMode, c0 as SearchAdapter, c1 as SearchEntry, c2 as SearchHits, c3 as SearchOptions, c4 as SearchQuery, c5 as SearchResults, c6 as SearchSetting, c7 as SelectField, c8 as SelectOption, c9 as Session, ca as SidebarPanel, cb as SignupArgs, cc as SignupResult, cd as SingleSignOn, ce as SlugField, cf as SlugOf, cg as SsoOutcome, ch as SsoProviderRef, ci as StorageAdapter, cj as StoredFile, ck as TRIGRAM_LIKE_MIN, cl as TelemetryConfig, cm as TextField, cn as TextareaField, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cr as UncountedDocs, cs as UpdateInput, ct as UpdateOptions, cu as UploadConfig, cv as UploadField, cw as UploadFromURLConfig, cx as UserIdentity, cy as ValidationError, cz as Version, cA as VersionSummary, cB as VersionsConfig, cC as WEBHOOK_EVENTS, cD as WebhookConfig, cE as WebhookEvent, cF as Where, cG as WhereOperators, cH as anyone, cI as consoleEmail, cJ as consoleLogger, cK as createEasyCMS, cL as defineConfig, cM as defineFieldType, cN as definePlugin, cO as diskStorage, cP as hasRows, cQ as isAdmin, cR as isHasMany, cS as isSignedIn, cT as isStaff, cU as isSystemAdmin, cV as jsonLogger, cW as memorySearch, cX as mimeAllowedBy, cY as rowFields, cZ as silentLogger, c_ as uniqueWithinOf } from './config-BuKlngv6.js';
|
|
3
|
+
export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-zOdLJ1oh.js';
|
|
4
|
+
export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-DuOjJu_X.js';
|
|
5
5
|
export { FieldCondition, matchesCondition } from './conditions.js';
|
|
6
6
|
|
|
7
7
|
type DeliveryKind = 'webhook' | 'email';
|
|
@@ -196,6 +196,12 @@ type AdminAttention =
|
|
|
196
196
|
id: 'failed-logins';
|
|
197
197
|
count: number;
|
|
198
198
|
}
|
|
199
|
+
/** Break-glass accounts signed in with a password in the last day (`auth.password`). */
|
|
200
|
+
| {
|
|
201
|
+
id: 'break-glass';
|
|
202
|
+
count: number;
|
|
203
|
+
emails: string[];
|
|
204
|
+
}
|
|
199
205
|
/** Audit log entries that could not be written since the server started. */
|
|
200
206
|
| {
|
|
201
207
|
id: 'audit-failures';
|
package/dist/index.js
CHANGED
package/dist/internal.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Config, Database, EasyCMS, NavGroup, CollectionConfig, ID, RawDocument, Field, ResolvedConfig, ConfigIssue } from './index.js';
|
|
2
|
-
export { c$ as API_KEYS, d0 as API_KEY_GLOBAL_OPERATIONS, d1 as API_KEY_OPERATIONS, d2 as DEFAULT_MAX_VERSIONS, d3 as DEFAULT_PASSWORD_EMAILS, d4 as FIELD_TYPE_BASES, d5 as FOLDER_LEVELS, d6 as MediaFolders, d7 as SSO_COOKIE, d8 as Secrets, ck as TRIGRAM_LIKE_MIN, d9 as codeOfStatus, cP as hasRows, cR as isHasMany, da as isJsonLogger, db as keyAllows, dc as logFormat, cX as mimeAllowedBy, cY as rowFields, dd as secretsOf, c_ as uniqueWithinOf } from './config-
|
|
3
|
-
export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as isEmail, y as mergeForUpdate, z as parseId, E as platformClientIp, F as readCookie, G as validateFields, H as writeBackupFile } from './handler-
|
|
2
|
+
export { c$ as API_KEYS, d0 as API_KEY_GLOBAL_OPERATIONS, d1 as API_KEY_OPERATIONS, d2 as DEFAULT_MAX_VERSIONS, d3 as DEFAULT_PASSWORD_EMAILS, d4 as FIELD_TYPE_BASES, d5 as FOLDER_LEVELS, d6 as MediaFolders, d7 as SSO_COOKIE, d8 as Secrets, ck as TRIGRAM_LIKE_MIN, d9 as codeOfStatus, cP as hasRows, cR as isHasMany, da as isJsonLogger, db as keyAllows, dc as logFormat, cX as mimeAllowedBy, cY as rowFields, dd as secretsOf, c_ as uniqueWithinOf } from './config-BuKlngv6.js';
|
|
3
|
+
export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as isEmail, y as mergeForUpdate, z as parseId, E as platformClientIp, F as readCookie, G as validateFields, H as writeBackupFile } from './handler-DuOjJu_X.js';
|
|
4
4
|
export { conditionIssues } from './conditions.js';
|
|
5
5
|
|
|
6
6
|
/** The session cookie the REST API sets at login. */
|
package/dist/internal.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { bh as Label, F as Field, I as ID, o as AdminLocale, x as AdminSwitcher, ch as SsoProviderRef } from './config-
|
|
1
|
+
import { bh as Label, F as Field, I as ID, o as AdminLocale, x as AdminSwitcher, ch as SsoProviderRef } from './config-BuKlngv6.js';
|
|
2
2
|
import { FieldCondition } from './conditions.js';
|
|
3
3
|
|
|
4
4
|
/** A Web Component from an admin module, with its `props` (plain JSON). */
|
|
@@ -211,6 +211,12 @@ interface AdminSchema {
|
|
|
211
211
|
switcher: AdminSwitcher | null;
|
|
212
212
|
/** Admins can email users links to set their password (needs `email` and `serverURL`). */
|
|
213
213
|
passwordLinks: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Who may have a password (`auth.password`); with `breakGlass`, the accounts, for admins of the
|
|
216
|
+
* whole system: others get no password field.
|
|
217
|
+
*/
|
|
218
|
+
passwordFor: 'everyone' | 'admins' | 'breakGlass';
|
|
219
|
+
breakGlass?: string[];
|
|
214
220
|
/** The menu: groups and what this user may open in them, in order. */
|
|
215
221
|
nav: AdminNavNode[];
|
|
216
222
|
/** More entries of the command palette (`admin.commands`). */
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { e as ADMIN_ICONS, k as AdminComponent, n as AdminIcon, $ as AuthUser, ac as CliCommand, ad as CollectionAccess, af as CollectionConfig, C as Config, al as ConfigError, at as DashboardWidget, aD as EasyCMSError, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, F as Field, aL as FieldAccess, a as FieldError, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, b2 as ForbiddenError, b4 as GlobalConfig, I as ID, be as JobConfig, bh as Label, bu as NavGroup, bv as NotFoundError, bx as OnRequest, by as OnRequestArgs, bB as PLUGIN_API_VERSION, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bM as QueryError, bR as RequestContext, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cy as ValidationError, cF as Where, cM as defineFieldType, cN as definePlugin } from './config-
|
|
2
|
-
export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-
|
|
1
|
+
export { e as ADMIN_ICONS, k as AdminComponent, n as AdminIcon, $ as AuthUser, ac as CliCommand, ad as CollectionAccess, af as CollectionConfig, C as Config, al as ConfigError, at as DashboardWidget, aD as EasyCMSError, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, F as Field, aL as FieldAccess, a as FieldError, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, b2 as ForbiddenError, b4 as GlobalConfig, I as ID, be as JobConfig, bh as Label, bu as NavGroup, bv as NotFoundError, bx as OnRequest, by as OnRequestArgs, bB as PLUGIN_API_VERSION, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bM as QueryError, bR as RequestContext, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cy as ValidationError, cF as Where, cM as defineFieldType, cN as definePlugin } from './config-BuKlngv6.js';
|
|
2
|
+
export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-zOdLJ1oh.js';
|
|
3
3
|
import './conditions.js';
|