@actuate-media/cms-core 0.35.1 → 0.37.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/__tests__/api/appearance-routes.test.d.ts +2 -0
- package/dist/__tests__/api/appearance-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/appearance-routes.test.js +134 -0
- package/dist/__tests__/api/appearance-routes.test.js.map +1 -0
- package/dist/__tests__/api/security-routes.test.d.ts +2 -0
- package/dist/__tests__/api/security-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/security-routes.test.js +212 -0
- package/dist/__tests__/api/security-routes.test.js.map +1 -0
- package/dist/__tests__/appearance/appearance.test.d.ts +2 -0
- package/dist/__tests__/appearance/appearance.test.d.ts.map +1 -0
- package/dist/__tests__/appearance/appearance.test.js +273 -0
- package/dist/__tests__/appearance/appearance.test.js.map +1 -0
- package/dist/__tests__/security/center.test.d.ts +2 -0
- package/dist/__tests__/security/center.test.d.ts.map +1 -0
- package/dist/__tests__/security/center.test.js +226 -0
- package/dist/__tests__/security/center.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +261 -3
- package/dist/api/handlers.js.map +1 -1
- package/dist/appearance/color.d.ts +24 -0
- package/dist/appearance/color.d.ts.map +1 -0
- package/dist/appearance/color.js +90 -0
- package/dist/appearance/color.js.map +1 -0
- package/dist/appearance/index.d.ts +13 -0
- package/dist/appearance/index.d.ts.map +1 -0
- package/dist/appearance/index.js +5 -0
- package/dist/appearance/index.js.map +1 -0
- package/dist/appearance/logo-shape.d.ts +26 -0
- package/dist/appearance/logo-shape.d.ts.map +1 -0
- package/dist/appearance/logo-shape.js +62 -0
- package/dist/appearance/logo-shape.js.map +1 -0
- package/dist/appearance/resolve.d.ts +46 -0
- package/dist/appearance/resolve.d.ts.map +1 -0
- package/dist/appearance/resolve.js +208 -0
- package/dist/appearance/resolve.js.map +1 -0
- package/dist/appearance/theme-tokens.d.ts +50 -0
- package/dist/appearance/theme-tokens.d.ts.map +1 -0
- package/dist/appearance/theme-tokens.js +198 -0
- package/dist/appearance/theme-tokens.js.map +1 -0
- package/dist/appearance/types.d.ts +110 -0
- package/dist/appearance/types.d.ts.map +1 -0
- package/dist/appearance/types.js +16 -0
- package/dist/appearance/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/security/center/gather.d.ts +12 -0
- package/dist/security/center/gather.d.ts.map +1 -0
- package/dist/security/center/gather.js +185 -0
- package/dist/security/center/gather.js.map +1 -0
- package/dist/security/center/index.d.ts +13 -0
- package/dist/security/center/index.d.ts.map +1 -0
- package/dist/security/center/index.js +4 -0
- package/dist/security/center/index.js.map +1 -0
- package/dist/security/center/settings.d.ts +38 -0
- package/dist/security/center/settings.d.ts.map +1 -0
- package/dist/security/center/settings.js +121 -0
- package/dist/security/center/settings.js.map +1 -0
- package/dist/security/center/status.d.ts +10 -0
- package/dist/security/center/status.d.ts.map +1 -0
- package/dist/security/center/status.js +249 -0
- package/dist/security/center/status.js.map +1 -0
- package/dist/security/center/summaries.d.ts +21 -0
- package/dist/security/center/summaries.d.ts.map +1 -0
- package/dist/security/center/summaries.js +65 -0
- package/dist/security/center/summaries.js.map +1 -0
- package/dist/security/center/types.d.ts +127 -0
- package/dist/security/center/types.d.ts.map +1 -0
- package/dist/security/center/types.js +12 -0
- package/dist/security/center/types.js.map +1 -0
- package/package.json +11 -1
package/dist/api/handlers.js
CHANGED
|
@@ -1133,10 +1133,34 @@ const MAX_CONCURRENT_SESSIONS = 5;
|
|
|
1133
1133
|
async function enforceSessionLimitsForUser(d, userId) {
|
|
1134
1134
|
if (!hasModel(d, 'session'))
|
|
1135
1135
|
return;
|
|
1136
|
+
// Precedence: explicitly-saved Security setting (database) ▸ auth config ▸
|
|
1137
|
+
// default. We read the stored value directly (rather than the parsed
|
|
1138
|
+
// defaults) so an unsaved policy doesn't shadow a config-provided override.
|
|
1139
|
+
let storedMax;
|
|
1140
|
+
try {
|
|
1141
|
+
if (hasModel(d, 'document')) {
|
|
1142
|
+
const row = await d.document.findFirst({
|
|
1143
|
+
where: { collection: 'settings', deletedAt: null },
|
|
1144
|
+
select: { data: true },
|
|
1145
|
+
});
|
|
1146
|
+
const stored = row?.data?.security;
|
|
1147
|
+
const v = stored?.session?.maxConcurrentSessions;
|
|
1148
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 0)
|
|
1149
|
+
storedMax = v;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
/* fall back to config/default below */
|
|
1154
|
+
}
|
|
1136
1155
|
const auth = getActuateConfig()?.auth;
|
|
1137
|
-
const max =
|
|
1138
|
-
?
|
|
1139
|
-
:
|
|
1156
|
+
const max = storedMax !== undefined
|
|
1157
|
+
? storedMax
|
|
1158
|
+
: typeof auth?.maxConcurrentSessions === 'number' && auth.maxConcurrentSessions > 0
|
|
1159
|
+
? auth.maxConcurrentSessions
|
|
1160
|
+
: MAX_CONCURRENT_SESSIONS;
|
|
1161
|
+
// 0 ⇒ unlimited: skip pruning entirely.
|
|
1162
|
+
if (max <= 0)
|
|
1163
|
+
return;
|
|
1140
1164
|
const active = await d.session.findMany({
|
|
1141
1165
|
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
1142
1166
|
select: { id: true, createdAt: true },
|
|
@@ -1445,10 +1469,29 @@ export function registerCMSRoutes(router) {
|
|
|
1445
1469
|
userAgent: userAgent ?? null,
|
|
1446
1470
|
},
|
|
1447
1471
|
});
|
|
1472
|
+
// Surface whether org-wide MFA is required but this account hasn't
|
|
1473
|
+
// enrolled yet (past its grace window). The admin shell uses this to
|
|
1474
|
+
// prompt enrollment. Computed best-effort; never blocks login (the
|
|
1475
|
+
// account can still set up TOTP from inside the admin).
|
|
1476
|
+
let mfaSetupRequired = false;
|
|
1477
|
+
try {
|
|
1478
|
+
const { getStoredSecuritySettings } = await import('../security/center/gather.js');
|
|
1479
|
+
const sec = await getStoredSecuritySettings(d);
|
|
1480
|
+
if (sec.mfa.required && !user.totpEnabled) {
|
|
1481
|
+
const graceMs = sec.mfa.gracePeriodDays * 24 * 60 * 60 * 1000;
|
|
1482
|
+
const requiredAt = sec.mfa.requiredAt ? Date.parse(sec.mfa.requiredAt) : 0;
|
|
1483
|
+
const createdAt = user.createdAt ? new Date(user.createdAt).getTime() : 0;
|
|
1484
|
+
mfaSetupRequired = Date.now() >= Math.max(requiredAt || 0, createdAt || 0) + graceMs;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
catch {
|
|
1488
|
+
/* policy unavailable — do not block login */
|
|
1489
|
+
}
|
|
1448
1490
|
const response = json({
|
|
1449
1491
|
data: {
|
|
1450
1492
|
token,
|
|
1451
1493
|
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
1494
|
+
mfaSetupRequired,
|
|
1452
1495
|
},
|
|
1453
1496
|
});
|
|
1454
1497
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
@@ -8897,6 +8940,212 @@ export function registerCMSRoutes(router) {
|
|
|
8897
8940
|
return internalError(err);
|
|
8898
8941
|
}
|
|
8899
8942
|
});
|
|
8943
|
+
// Public brand: resolved logos + favicon for the public frontend. No auth —
|
|
8944
|
+
// brand assets are public site data. Only the PUBLIC brand view + favicon are
|
|
8945
|
+
// exposed here (never the admin-only brand) so a white-labeled admin doesn't
|
|
8946
|
+
// leak through the public API.
|
|
8947
|
+
router.get('/public/brand', async (request) => {
|
|
8948
|
+
try {
|
|
8949
|
+
const { resolveBrand } = await import('../appearance/index.js');
|
|
8950
|
+
const resolved = await resolveBrand(db(), null);
|
|
8951
|
+
return cachedRead(request, {
|
|
8952
|
+
cache: getActuateConfig()?.cache,
|
|
8953
|
+
tags: [ACTUATE_TAG_ROOT, actuateTag('global', 'settings')],
|
|
8954
|
+
wrap: jsonWithCache,
|
|
8955
|
+
build: async () => ({
|
|
8956
|
+
data: { public: resolved.public, favicon: resolved.favicon },
|
|
8957
|
+
}),
|
|
8958
|
+
});
|
|
8959
|
+
}
|
|
8960
|
+
catch (err) {
|
|
8961
|
+
return internalError(err, 'public/brand GET');
|
|
8962
|
+
}
|
|
8963
|
+
});
|
|
8964
|
+
// Authenticated appearance read: stored appearance + brand settings plus the
|
|
8965
|
+
// fully resolved admin/public/favicon views (with asset URLs) so the admin
|
|
8966
|
+
// Appearance tab can render live previews. View-only auth — writes go through
|
|
8967
|
+
// PUT /globals/settings (ADMIN_ROLES), so editors can view but not save.
|
|
8968
|
+
router.get('/appearance', async (request) => {
|
|
8969
|
+
try {
|
|
8970
|
+
const auth = await requireAuth(request);
|
|
8971
|
+
if (auth.error)
|
|
8972
|
+
return auth.error;
|
|
8973
|
+
const { getAppearanceSettings, getBrandSettings, resolveBrand } = await import('../appearance/index.js');
|
|
8974
|
+
const [appearance, brand, resolved] = await Promise.all([
|
|
8975
|
+
getAppearanceSettings(db()),
|
|
8976
|
+
getBrandSettings(db()),
|
|
8977
|
+
resolveBrand(db(), null),
|
|
8978
|
+
]);
|
|
8979
|
+
return json({
|
|
8980
|
+
data: {
|
|
8981
|
+
appearance,
|
|
8982
|
+
brand,
|
|
8983
|
+
resolved: { admin: resolved.admin, public: resolved.public, favicon: resolved.favicon },
|
|
8984
|
+
// Direct per-asset URLs so the editor shows each field's actual
|
|
8985
|
+
// asset (not the resolved fallback chain).
|
|
8986
|
+
assets: resolved.assets,
|
|
8987
|
+
siteTitle: resolved.siteTitle,
|
|
8988
|
+
},
|
|
8989
|
+
});
|
|
8990
|
+
}
|
|
8991
|
+
catch (err) {
|
|
8992
|
+
return internalError(err, 'appearance GET');
|
|
8993
|
+
}
|
|
8994
|
+
});
|
|
8995
|
+
// ---------------------------------------------------------------------------
|
|
8996
|
+
// Security Center
|
|
8997
|
+
// GET /security — computed posture + summaries
|
|
8998
|
+
// PUT /security — persist enforced policy (lockout-safe)
|
|
8999
|
+
// POST /security/sessions/revoke-all — revoke the caller's other sessions
|
|
9000
|
+
// ---------------------------------------------------------------------------
|
|
9001
|
+
router.get('/security', async (request) => {
|
|
9002
|
+
try {
|
|
9003
|
+
const auth = await requireAuth(request);
|
|
9004
|
+
if (auth.error)
|
|
9005
|
+
return auth.error;
|
|
9006
|
+
const adminErr = requireAdminScope(auth.session);
|
|
9007
|
+
if (adminErr)
|
|
9008
|
+
return adminErr;
|
|
9009
|
+
const { getSecurityCenterData } = await import('../security/center/gather.js');
|
|
9010
|
+
const data = await getSecurityCenterData({
|
|
9011
|
+
db: db(),
|
|
9012
|
+
userId: auth.session.userId,
|
|
9013
|
+
currentIp: getClientIp(request),
|
|
9014
|
+
});
|
|
9015
|
+
return json({ data });
|
|
9016
|
+
}
|
|
9017
|
+
catch (err) {
|
|
9018
|
+
return internalError(err, 'security GET');
|
|
9019
|
+
}
|
|
9020
|
+
});
|
|
9021
|
+
router.put('/security', async (request) => {
|
|
9022
|
+
try {
|
|
9023
|
+
const auth = await requireAuth(request);
|
|
9024
|
+
if (auth.error)
|
|
9025
|
+
return auth.error;
|
|
9026
|
+
const adminErr = requireAdminScope(auth.session);
|
|
9027
|
+
if (adminErr)
|
|
9028
|
+
return adminErr;
|
|
9029
|
+
const body = (await request.json());
|
|
9030
|
+
const { parseSecuritySettings, validateSecuritySettings, mfaEnableLockoutError } = await import('../security/center/settings.js');
|
|
9031
|
+
const { getStoredSecuritySettings, getSecurityCenterData } = await import('../security/center/gather.js');
|
|
9032
|
+
const incoming = parseSecuritySettings({ security: body });
|
|
9033
|
+
const issues = validateSecuritySettings(incoming);
|
|
9034
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
9035
|
+
if (errors.length > 0) {
|
|
9036
|
+
return json({ error: errors[0].message, issues }, 400);
|
|
9037
|
+
}
|
|
9038
|
+
const d = db();
|
|
9039
|
+
const current = await getStoredSecuritySettings(d);
|
|
9040
|
+
// Lockout-safe: enabling org-wide MFA requires the acting admin to have
|
|
9041
|
+
// their own MFA configured. Enforced server-side — never trust the client.
|
|
9042
|
+
const enabling = incoming.mfa.required && !current.mfa.required;
|
|
9043
|
+
if (enabling) {
|
|
9044
|
+
let viewerHasMfa = false;
|
|
9045
|
+
let totalAdmins = 0;
|
|
9046
|
+
if (hasModel(d, 'user')) {
|
|
9047
|
+
const viewer = await d.user
|
|
9048
|
+
.findUnique({ where: { id: auth.session.userId }, select: { totpEnabled: true } })
|
|
9049
|
+
.catch(() => null);
|
|
9050
|
+
viewerHasMfa = Boolean(viewer?.totpEnabled);
|
|
9051
|
+
totalAdmins = await d.user
|
|
9052
|
+
.count({ where: { role: 'ADMIN', isActive: true } })
|
|
9053
|
+
.catch(() => 0);
|
|
9054
|
+
}
|
|
9055
|
+
const lockErr = mfaEnableLockoutError({ enabling: true, viewerHasMfa, totalAdmins });
|
|
9056
|
+
if (lockErr) {
|
|
9057
|
+
return json({ error: lockErr, code: 'MFA_LOCKOUT_RISK' }, 409);
|
|
9058
|
+
}
|
|
9059
|
+
}
|
|
9060
|
+
// Stamp `requiredAt` when MFA requirement flips on (drives the grace
|
|
9061
|
+
// window); preserve it across saves while it stays on; clear when off.
|
|
9062
|
+
const requiredAt = incoming.mfa.required
|
|
9063
|
+
? current.mfa.required
|
|
9064
|
+
? current.mfa.requiredAt
|
|
9065
|
+
: new Date().toISOString()
|
|
9066
|
+
: undefined;
|
|
9067
|
+
const securityBlob = {
|
|
9068
|
+
mfa: {
|
|
9069
|
+
required: incoming.mfa.required,
|
|
9070
|
+
gracePeriodDays: incoming.mfa.gracePeriodDays,
|
|
9071
|
+
...(requiredAt ? { requiredAt } : {}),
|
|
9072
|
+
},
|
|
9073
|
+
session: { maxConcurrentSessions: incoming.session.maxConcurrentSessions },
|
|
9074
|
+
updatedAt: new Date().toISOString(),
|
|
9075
|
+
updatedBy: auth.session.userId,
|
|
9076
|
+
};
|
|
9077
|
+
const ctx = buildActionContext(auth.session, d);
|
|
9078
|
+
const prevGlobal = (await getGlobal('settings', ctx).catch(() => ({}))) ?? {};
|
|
9079
|
+
await updateGlobal('settings', { ...prevGlobal, security: securityBlob }, ctx);
|
|
9080
|
+
const changes = [];
|
|
9081
|
+
if (current.mfa.required !== incoming.mfa.required)
|
|
9082
|
+
changes.push({ key: 'mfa.required', from: current.mfa.required, to: incoming.mfa.required });
|
|
9083
|
+
if (current.mfa.gracePeriodDays !== incoming.mfa.gracePeriodDays)
|
|
9084
|
+
changes.push({
|
|
9085
|
+
key: 'mfa.gracePeriodDays',
|
|
9086
|
+
from: current.mfa.gracePeriodDays,
|
|
9087
|
+
to: incoming.mfa.gracePeriodDays,
|
|
9088
|
+
});
|
|
9089
|
+
if (current.session.maxConcurrentSessions !== incoming.session.maxConcurrentSessions)
|
|
9090
|
+
changes.push({
|
|
9091
|
+
key: 'session.maxConcurrentSessions',
|
|
9092
|
+
from: current.session.maxConcurrentSessions,
|
|
9093
|
+
to: incoming.session.maxConcurrentSessions,
|
|
9094
|
+
});
|
|
9095
|
+
if (changes.length > 0) {
|
|
9096
|
+
void logEvent({
|
|
9097
|
+
event: 'security_settings_changed',
|
|
9098
|
+
userId: auth.session.userId,
|
|
9099
|
+
ipAddress: getClientIp(request),
|
|
9100
|
+
details: {
|
|
9101
|
+
source: 'database',
|
|
9102
|
+
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
|
|
9103
|
+
changes,
|
|
9104
|
+
},
|
|
9105
|
+
});
|
|
9106
|
+
}
|
|
9107
|
+
const data = await getSecurityCenterData({
|
|
9108
|
+
db: d,
|
|
9109
|
+
userId: auth.session.userId,
|
|
9110
|
+
currentIp: getClientIp(request),
|
|
9111
|
+
});
|
|
9112
|
+
return json({ data });
|
|
9113
|
+
}
|
|
9114
|
+
catch (err) {
|
|
9115
|
+
return internalError(err, 'security PUT');
|
|
9116
|
+
}
|
|
9117
|
+
});
|
|
9118
|
+
router.post('/security/sessions/revoke-all', async (request) => {
|
|
9119
|
+
try {
|
|
9120
|
+
const auth = await requireAuth(request);
|
|
9121
|
+
if (auth.error)
|
|
9122
|
+
return auth.error;
|
|
9123
|
+
const d = db();
|
|
9124
|
+
if (!hasModel(d, 'session'))
|
|
9125
|
+
return json({ data: { revoked: 0 } });
|
|
9126
|
+
// Revoke every OTHER active session for the caller — their current
|
|
9127
|
+
// session stays valid so they aren't logged out of the admin they're in.
|
|
9128
|
+
const result = await d.session.updateMany({
|
|
9129
|
+
where: {
|
|
9130
|
+
userId: auth.session.userId,
|
|
9131
|
+
id: { not: auth.session.sessionId },
|
|
9132
|
+
revokedAt: null,
|
|
9133
|
+
},
|
|
9134
|
+
data: { revokedAt: new Date() },
|
|
9135
|
+
});
|
|
9136
|
+
const revoked = typeof result?.count === 'number' ? result.count : 0;
|
|
9137
|
+
void logEvent({
|
|
9138
|
+
event: 'sessions_revoked_all',
|
|
9139
|
+
userId: auth.session.userId,
|
|
9140
|
+
ipAddress: getClientIp(request),
|
|
9141
|
+
details: { count: revoked, scope: 'self_other' },
|
|
9142
|
+
});
|
|
9143
|
+
return json({ data: { revoked } });
|
|
9144
|
+
}
|
|
9145
|
+
catch (err) {
|
|
9146
|
+
return internalError(err, 'security/sessions/revoke-all');
|
|
9147
|
+
}
|
|
9148
|
+
});
|
|
8900
9149
|
router.get('/globals/:slug', async (request, params) => {
|
|
8901
9150
|
try {
|
|
8902
9151
|
const auth = await requireAuth(request);
|
|
@@ -8946,8 +9195,17 @@ export function registerCMSRoutes(router) {
|
|
|
8946
9195
|
// Audit settings changes (title/tagline/URL/locale/timezone/etc.) with a
|
|
8947
9196
|
// per-key before/after diff so the audit trail records what changed and
|
|
8948
9197
|
// who changed it. Scalar values only — we never log nested secret blobs.
|
|
9198
|
+
// Nested config blobs (`appearance`, `brand`) are logged as a changed
|
|
9199
|
+
// key (not their contents) so appearance/branding edits are auditable
|
|
9200
|
+
// without dumping asset IDs or theme objects into the log.
|
|
8949
9201
|
try {
|
|
8950
9202
|
const changes = diffScalarKeys(previous ?? {}, body);
|
|
9203
|
+
const prev = (previous ?? {});
|
|
9204
|
+
for (const key of ['appearance', 'brand', 'security']) {
|
|
9205
|
+
if (key in body && JSON.stringify(prev[key]) !== JSON.stringify(body[key])) {
|
|
9206
|
+
changes.push({ key, from: '[changed]', to: '[changed]' });
|
|
9207
|
+
}
|
|
9208
|
+
}
|
|
8951
9209
|
if (changes.length > 0) {
|
|
8952
9210
|
void logEvent({
|
|
8953
9211
|
event: 'settings_changed',
|