@actuate-media/cms-core 0.36.0 → 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.
Files changed (36) hide show
  1. package/dist/__tests__/api/security-routes.test.d.ts +2 -0
  2. package/dist/__tests__/api/security-routes.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/security-routes.test.js +212 -0
  4. package/dist/__tests__/api/security-routes.test.js.map +1 -0
  5. package/dist/__tests__/security/center.test.d.ts +2 -0
  6. package/dist/__tests__/security/center.test.d.ts.map +1 -0
  7. package/dist/__tests__/security/center.test.js +226 -0
  8. package/dist/__tests__/security/center.test.js.map +1 -0
  9. package/dist/api/handlers.d.ts.map +1 -1
  10. package/dist/api/handlers.js +201 -4
  11. package/dist/api/handlers.js.map +1 -1
  12. package/dist/security/center/gather.d.ts +12 -0
  13. package/dist/security/center/gather.d.ts.map +1 -0
  14. package/dist/security/center/gather.js +185 -0
  15. package/dist/security/center/gather.js.map +1 -0
  16. package/dist/security/center/index.d.ts +13 -0
  17. package/dist/security/center/index.d.ts.map +1 -0
  18. package/dist/security/center/index.js +4 -0
  19. package/dist/security/center/index.js.map +1 -0
  20. package/dist/security/center/settings.d.ts +38 -0
  21. package/dist/security/center/settings.d.ts.map +1 -0
  22. package/dist/security/center/settings.js +121 -0
  23. package/dist/security/center/settings.js.map +1 -0
  24. package/dist/security/center/status.d.ts +10 -0
  25. package/dist/security/center/status.d.ts.map +1 -0
  26. package/dist/security/center/status.js +249 -0
  27. package/dist/security/center/status.js.map +1 -0
  28. package/dist/security/center/summaries.d.ts +21 -0
  29. package/dist/security/center/summaries.d.ts.map +1 -0
  30. package/dist/security/center/summaries.js +65 -0
  31. package/dist/security/center/summaries.js.map +1 -0
  32. package/dist/security/center/types.d.ts +127 -0
  33. package/dist/security/center/types.d.ts.map +1 -0
  34. package/dist/security/center/types.js +12 -0
  35. package/dist/security/center/types.js.map +1 -0
  36. package/package.json +6 -1
@@ -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 = typeof auth?.maxConcurrentSessions === 'number' && auth.maxConcurrentSessions > 0
1138
- ? auth.maxConcurrentSessions
1139
- : MAX_CONCURRENT_SESSIONS;
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';
@@ -8949,6 +8992,160 @@ export function registerCMSRoutes(router) {
8949
8992
  return internalError(err, 'appearance GET');
8950
8993
  }
8951
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
+ });
8952
9149
  router.get('/globals/:slug', async (request, params) => {
8953
9150
  try {
8954
9151
  const auth = await requireAuth(request);
@@ -9004,7 +9201,7 @@ export function registerCMSRoutes(router) {
9004
9201
  try {
9005
9202
  const changes = diffScalarKeys(previous ?? {}, body);
9006
9203
  const prev = (previous ?? {});
9007
- for (const key of ['appearance', 'brand']) {
9204
+ for (const key of ['appearance', 'brand', 'security']) {
9008
9205
  if (key in body && JSON.stringify(prev[key]) !== JSON.stringify(body[key])) {
9009
9206
  changes.push({ key, from: '[changed]', to: '[changed]' });
9010
9207
  }