CVE-2021-25981: Insufficient Session Expiration (CWE-613)
value: In Talkyard, regular versions v0.2021.20 through v0.2021.33 and dev versions v0.2021.20 through v0.2021.34, are vulnerable to Insufficient Session Expiration. This may allow an attacker to reuse the admin’s still-valid session token even when logged-out, to gain admin privileges, given the attacker is able to obtain that token (via other, hypothetical attacks)
CWE description: According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
CVSS v3 base score: 9.8
Published: 2022-01-03T07:15Z
Repository: https://github.com/debiki/talkyard
Language: Scala
Files changed: appsv/model/src/main/scala/com/debiki/core/Prelude.scala, appsv/model/src/main/scala/com/debiki/core/Site.scala, appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala, appsv/model/src/main/scala/com/debiki/core/SystemTransaction.scala, appsv/model/src/main/scala/com/debiki/core/TySession.scala, appsv/model/src/main/scala/com/debiki/core/package.scala, appsv/rdb/src/main/scala/com/debiki/dao/rdb/CreateSiteSystemDaoMixin.scala, appsv/rdb/src/main/scala/com/debiki/dao/rdb/Rdb.scala, appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSiteTransaction.scala, appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSystemTransaction.scala, appsv/rdb/src/main/scala/com/debiki/dao/rdb/SessionsRdbMixin.scala, appsv/server/controllers/DebugTestController.scala, appsv/server/controllers/DraftsController.scala, appsv/server/controllers/EditController.scala, appsv/server/controllers/EmbeddedTopicsController.scala, appsv/server/controllers/FlagController.scala, appsv/server/controllers/ImpersonateController.scala, appsv/server/controllers/InviteController.scala, appsv/server/controllers/LoginAsGuestController.scala, appsv/server/controllers/LoginController.scala, appsv/server/controllers/LoginWithOpenAuthController.scala, appsv/server/controllers/LoginWithPasswordController.scala, appsv/server/controllers/ReplyController.scala, appsv/server/controllers/ResetPasswordController.scala, appsv/server/controllers/UserController.scala, appsv/server/controllers/ViewPageController.scala, appsv/server/controllers/VoteController.scala, appsv/server/debiki/JsonUtils.scala, appsv/server/debiki/ReactJson.scala, appsv/server/debiki/dao/SiteDao.scala, appsv/server/debiki/dao/SystemDao.scala, appsv/server/debiki/dao/UserDao.scala, appsv/server/ed/server/EdAppLoader.scala, appsv/server/ed/server/EdController.scala, appsv/server/ed/server/auth/AuthzSiteDaoMixin.scala, appsv/server/ed/server/http/DebikiRequest.scala, appsv/server/ed/server/http/PageRequest.scala, appsv/server/ed/server/http/PlainApiActions.scala, appsv/server/ed/server/http/package.scala, appsv/server/ed/server/jobs/Janitor.scala, appsv/server/ed/server/pubsub/SubscriberController.scala, appsv/server/ed/server/security/package.scala, appsv/server/talkyard/server/JsX.scala, appsv/server/talkyard/server/api/QueryDoController.scala, appsv/server/talkyard/server/authn/SsoAuthnController.scala, appsv/server/talkyard/server/authn/package.scala, appsv/server/talkyard/server/http/package.scala, appsv/server/talkyard/server/sess/SessionController.scala, appsv/server/talkyard/server/sess/SessionSiteDaoMixin.scala, client/app-head/head-bundle.ts, client/app-more/login/login-dialog.more.ts, client/app-more/topbar/my-menu.more.ts, client/app-more/users/user-preferences.more.ts, client/app-slim/ReactActions.ts, client/app-slim/Server.ts, client/app-slim/if-in-iframe.ts, client/app-slim/login/login-if-needed.ts, client/app-slim/me-getters.ts, client/app-slim/model.ts, client/app-slim/oop-methods.ts, client/app-slim/prelude.ts, client/app-slim/slim-bundle.d.ts, client/app-slim/start-page.ts, client/app-slim/translations.d.ts, client/embedded-comments/blog-comments.ts, client/types-and-const-enums.ts, conf/routes, docs/maybe-do-later.txt, docs/naming-notes.md, docs/tests-map.txt, s/impl/tyd-e2e-tests.ts, s/run-e2e-tests.sh, tests/app/debiki/dao/UserStatsAppSpec.scala, tests/e2e-wdio7/specs/d.sessions-logout-elsewhere.4br.e2e.ts, tests/e2e-wdio7/specs/d.sessions-staff-logout-others.4br.e2e.ts, tests/e2e-wdio7/specs/embcom.expimpjson.import-tests-impl.ts, tests/e2e-wdio7/specs/embcom.sessions-emb-sess-cannot-moderate.3br.e2e.ts, tests/e2e-wdio7/test-types.ts, tests/e2e-wdio7/test-types2.ts, tests/e2e-wdio7/utils/settings-exp-def.ts, tests/e2e-wdio7/utils/ty-e2e-test-browser.ts, tests/e2e-wdio7/wdio.conf.ts, tests/e2e/specs/admin-user-suspend.2browsers.test.ts, tests/e2e/specs/sso-test.2browsers.test.ts, translations/en_US/i18n.ts
Commit date: 2021-11-16 06:10:58 +0100
Commit message: Better session ids, 5 parts, feature flag to enable.

Diff: diff --git a/appsv/model/src/main/scala/com/debiki/core/Prelude.scala b/appsv/model/src/main/scala/com/debiki/core/Prelude.scala
index 3f8b74401..d56b31cfd 100644
--- a/appsv/model/src/main/scala/com/debiki/core/Prelude.scala
+++ b/appsv/model/src/main/scala/com/debiki/core/Prelude.scala
@@ -21,11 +21,11 @@ import org.apache.commons.codec.binary.Hex
 
 import java.{util => ju}
 import java.{security => js}
 import org.apache.commons.codec.{binary => acb}
 import org.scalactic.{ErrorMessage, Or}
-import play.api.libs.json.{JsNumber, JsObject, JsString}
+import play.api.libs.json._
 
 import scala.collection.mutable
 import scala.util.Try
 import scala.util.matching.Regex
 import scala.collection.immutable
@@ -62,17 +62,31 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
   // or release mode.
 
   // Should get rid of this version:
   def warnDbgDie(errorMsg: String): Unit = { warnDbgDie("", errorMsg) }
 
-  def warnDbgDieIf(test: => Bo, errorCode: St, details: St): U = {
+  def warnDevDieIf(test: => Bo, errorCode: St, details: St = ""): U =
+    warnDbgDieIf(test, errorCode, details)
+
+  RENAME // to ..DevDie..?
+  def warnDbgDieIf(test: => Bo, errorCode: St, details: St = ""): U = {
     if (test) {
       warnDbgDie(errorCode, details)
     }
   }
 
+  def warnDevDieUnless(test: => Bo, errorCode: St, details: St = ""): U = {
+    if (!test) {
+      warnDbgDie(errorCode, details)
+    }
+  }
+
   def warnDbgDie(errorCode: St, warningMsg: St): U = {
+    warnDevDie(errorCode, warningMsg)
+  }
+
+  def warnDevDie(errorCode: St, warningMsg: St = ""): U = {
     if (true) {
       // Fail hard in debug mode so this error will be fixed.
       throw new AssertionError(s"$warningMsg [$errorCode]")
     }
     else {
@@ -580,17 +594,38 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
     while (result < min)
     result
   }
 
 
-  /** Generates a 130 bit string, almost 26 chars long since each char in a 32 chars
-    * alphabet has 5 bits (but we use 36 chars here).
+  /** Generates a by default 130 bits entropy string, almost 26 chars long since
+    * each char in a 32 chars alphabet has 5 bits (but we use 36 chars here).
     * Wikipedia says: "128-bit keys are commonly used and considered very strong".
     * Here: http://en.wikipedia.org/wiki/Key_(cryptography)
     */
-  def nextRandomString(): String =
-    new java.math.BigInteger(130, _random).toString(36) // 0..9, a..z
+  def nextRandomString(bitsEntropy: i32 = 130, base36: Bo = true,
+          base64UrlSafe: Bo = false): St = {
+    require(base36 != base64UrlSafe)
+    if (base36) {
+      // Hmm the resulting length is a bit random — if the BigInteger happens to start
+      // with 0, the base 36 encoding will be shorter (leading zeroes are excluded).
+      val bigInt = new java.math.BigInteger(bitsEntropy, _random)
+      bigInt.toString(36) // 0..9, a..z
+    }
+    else {
+      assert(base64UrlSafe)
+      // Divisible by 8, so byte aligned — _random operates on bytes.
+      assert(bitsEntropy % 8 == 0)
+      // Divisible by 6, so always result in same length Base64 repr.
+      assert(bitsEntropy % 6 == 0)
+      val numBytes = bitsEntropy / 8
+      val bytesArray = new Array[Byte](numBytes)
+      _random.nextBytes(bytesArray)
+      val res = acb.Base64.encodeBase64URLSafeString(bytesArray)
+      assert(res.length * 6 == bitsEntropy)
+      res
+    }
+  }
 
   // COULD split this in a random string function, and other id generation
   // functions, with string length adjusted, depending on how the random
   // string will be used.
   def nextRandomPageId(): String = {  // TODO exclude vowels, so no bad words
@@ -646,16 +681,30 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
   val hashLengthIp = 20
 
   def saltAndHashEmail: St => St = saltAndHash(hashLengthEmail) _
   def saltAndHashIp: St => St = saltAndHash(hashLengthIp) _
 
-  SECURITY; COULD // use SHA-256 instead.
-  private def mdSha1 = js.MessageDigest.getInstance("SHA-1") // not thread safe
+  SECURITY; COULD_OPTIMIZE // use BLAKE3 instead.
+  private def mdSha1: js.MessageDigest = js.MessageDigest.getInstance("SHA-1") // not thread safe
+
+  /// SHA-512/256 is faster and stronger than SHA-224 (not in a way that matters, but anyway).
+  /// And BLAKE3 has a 256 bits output too by default.
+  private def mdSha512: js.MessageDigest =
+    js.MessageDigest.getInstance("SHA-512") // not thread safe
 
   def hashSha1Base64UrlSafe(text: String): String =
     acb.Base64.encodeBase64URLSafeString(mdSha1.digest(text.getBytes("UTF-8")))
 
+  def hashSha512FirstHalf32Bytes(text: St): Array[i8] = {
+    // I think this isn't the real SHA512/256, but this'll be ok too.
+    // SHA-512/256 not incl in Java 8.
+    // Later: Use BLAKE3, the Rust implementation, call from Java.
+    val res = mdSha512.digest(text.getBytes("UTF-8"))
+    assert(res.length == 32 * 2)
+    res take 32
+  }
+
 
   /* ------  Move to a 'security' package?    [406MRED256]
   def base32EncodeSecretKey(key: javax.crypto.SecretKey): St = {
     val b32 = new acb.Base32(99999)  // "unlimited" line length
     b32.encodeToString(key.getEncoded)
@@ -787,39 +836,112 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
 
 
   private val AToZUnderscoreRegex = "^[a-zA-Z_]*$".r
   private val VariableNameRegex = "^[a-zA-Z_][a-zA-Z0-9_]*$".r
   private val AlNumWithAl = "^[a-zA-Z0-9_]*[a-zA-Z_][a-zA-Z0-9_]*$".r
+  private val AlNumDashRegex = "^[a-zA-Z0-9_-]*$".r
 
   /** Checks that all fields names are okay variable names,
     * and that all values are numbers, or also okay variable names.
     * Just to avoid any unexpected things like some kind of injection.
     */
-  def anyWeirdJsObjField(obj: JsObject, maxLength: Int): Option[String] = {
+  def anyWeirdJsObjField(obj: JsObject, maxLength: i32,
+           allowHeaderNameValues: Bo = false): Opt[St] = {
+    unimplIf(allowHeaderNameValues, "TyE50MFEDJ4601")
     for ((fieldName, fieldValue) <- obj.fields) {
-      if (fieldName.isEmpty) return Some("Empty field name")
-      if (!fieldName.isOkVariableName) return Some(s"Weird field name: $fieldName")
-      if (fieldName.length > maxLength) return Some(s"Too long field name: $fieldName")
+      if (fieldName.isEmpty)
+        return Some("Empty field name")
+
+      if (allowHeaderNameValues) {
+        if (!fieldName.isOkHeaderName)
+          return Some(s"Weird header name: '$fieldName' [TyE0HDRNAME]")
+      }
+      else {
+        if (!fieldName.isOkVariableName)
+          return Some(s"Weird field name: '$fieldName' [TyE0VARNAME]")
+      }
+
+      if (fieldName.length > maxLength)
+        return Some(s"Too long field name: '$fieldName' [TyE2LNGFLDNM]")
+
       fieldValue match {
         case _: JsNumber =>
           // Fine
         case s: JsString =>
-          if (s.value.isEmpty) return Some(s"Empty value for field $fieldName")
-          if (!s.value.isOkVariableName) return Some(s"Bad value for field $fieldName: $fieldValue")
-          if (s.value.length > maxLength) return Some(s"Too long field value, $fieldName: $fieldValue")
+          if (s.value.isEmpty)
+            return Some(s"Empty value for field $fieldName")
+
+          if (allowHeaderNameValues) {
+            // Don't allow newlines in value?
+            unimpl("TyE50MFEDJ4602")
+          }
+          else if (!s.value.isOkVariableName) {
+            return Some(s"Bad value for field $fieldName: '$fieldValue'")
+          }
+
+          if (s.value.length > maxLength)
+            return Some(s"Too long field value, $fieldName: '$fieldValue'")
+
         case _ =>
-          return Some(s"Value of field $fieldName is weird")
+          return Some(s"Value of field $fieldName is not a nummer or string; it is a ${
+            classNameOf(fieldValue)} [TyEFIELDVALTYP]")
       }
     }
     None
   }
 
   /** Default value doesn't work with anyJsonObj.flatMap(anyWeirdJsObjField)
     */
   def anyWeirdJsObjField(obj: JsObject): Option[String] =
     anyWeirdJsObjField(obj, maxLength = 100)
 
+  def jsObjectSize(obj: JsObject, depth: i32 = 0): i32 = {
+    TESTS_MISSING
+    var size = 0
+    for ((fieldName, value) <- obj.fields) {
+      size += fieldName.length + jsValueSize(value, depth = depth + 1)
+    }
+    size
+  }
+
+  private def jsValueSize(value: JsValue, depth: i32 = 0): i32 = {
+    TESTS_MISSING
+    if (depth > 10)
+      return Int.MaxValue // for now
+    value match {
+      case JsNull => 4
+      case _: JsBoolean => 4
+      case _: JsNumber => 4 // let's just guess 4 bytes. Or log-10?
+      case s: JsString => s.value.length
+      case _: JsArray =>
+        // What about infinitely deeply nested empty arrays in arrays! Would be size 0 :-(
+        Int.MaxValue // for now
+        // a.value.foldLeft(0)((len, v) => {
+        //   jsValueSize(v, sizeThisFar + len, maxAllowedSize, depth = depth + 1)
+        // })
+      case _: JsObject =>
+        Int.MaxValue // for now
+        //jsObjectSize(o)
+      case _ =>
+        // What's this?
+        assert(false)
+        Int.MaxValue
+    }
+  }
+
+  // Move to where?
+  val JsEmptyObj2: JsObject = JsObject(Nil)
+
+  /*
+  def mapKeyValuesTotalLength(map: Map[St, Any], depth: i32 = 0): i32 = {
+    var size = 0
+    for ((fieldName, value) <- map) {
+      size += fieldName.length + jsValueSize(value, depth = depth + 1)
+      // or:   + value match { case n: some-number: ...  case s: St => s.length  case _ => no! }
+    }
+    size
+  } */
 
   /**
    * Pimps `String` with `matches(regex): Boolean` and `misses(regex)`
    * and `dropRightWhile(Char => Boolean)` and `takeRightWhile`.
    */
@@ -872,10 +994,13 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
       AToZUnderscoreRegex.pattern.matcher(underlying).matches
 
     def isOkVariableName: Boolean =
       VariableNameRegex.pattern.matcher(underlying).matches
 
+    def isOkHeaderName: Bo =
+      underlying.nonEmpty && AlNumDashRegex.pattern.matcher(underlying).matches
+
     def isAlNum: Bo =
       underlying.forall(charIsAzOrNum)
 
     def isAzLowerOrNum: Bo =
       underlying.forall(charIsAzLowerOrNum)
diff --git a/appsv/model/src/main/scala/com/debiki/core/Site.scala b/appsv/model/src/main/scala/com/debiki/core/Site.scala
index e1c841dfb..fbc5c560f 100644
--- a/appsv/model/src/main/scala/com/debiki/core/Site.scala
+++ b/appsv/model/src/main/scala/com/debiki/core/Site.scala
@@ -70,21 +70,48 @@ object Site {
   private val OkWebsiteNameRegex = """[a-z][a-z0-9\-]*[a-z0-9]""".r
 
 }
 
 
+trait SiteTrait {
+  def id: SiteId
+  def pubId: PubSiteId
+  def status: SiteStatus
+  def featureFlags: St
+
+  def isTestSite: Bo = id <= Site.MaxTestSiteId
+
+  def isFeatureEnabled(ffName: St, serverFeatureFlags: St): Bo = {
+    val offName = "0" + ffName  // zero  — same as when disabling options in Vim
+    val enabledWholeServer = serverFeatureFlags.contains(ffName)
+    val disabledWholeServer = serverFeatureFlags.contains(offName)
+    val enabledThisSite = featureFlags.contains(ffName)
+    val disabledThisSite = featureFlags.contains(offName)
+    val enabledSomewhere = enabledWholeServer || enabledThisSite
+    val disabledSomewhere = disabledWholeServer || disabledThisSite
+    // By default a feature flag is not enabled, and can be enabled in a   [ff_on_off]
+    // specific site only via this.featureFlags. So, if a feature has been
+    // disabled explicitly in the whole server, then, that overrides
+    // it being enabled per site (so it'll be disabled everywhere) (Otherwise
+    // disabledWholeServer would be pointless.)
+    // However, if a feature is enabled by default (for all sites), then,
+    // a site can disable it.
+    enabledSomewhere && !disabledSomewhere
+  }
+}
+
+
 /**
   * @param hostname — doesn't include any port number.
   */
 case class SiteBrief(
   id: SiteId,
   pubId: PubSiteId,
   hostname: Opt[St],
   status: SiteStatus,
   featureFlags: St,
-) {
-  def isTestSite: Bo = id <= Site.MaxTestSiteId
+) extends SiteTrait {
 }
 
 
 
 sealed abstract class SiteStatus(val IntValue: i32) {
@@ -215,22 +242,20 @@ case class Site(  // Remove? Use SiteBrief or SiteDetailed instead?
   creatorIp: String,
   hostnames: Vector[Hostname],
   readLimitsMultiplier: Opt[f32],
   logLimitsMultiplier: Opt[f32],
   createLimitsMultiplier: Opt[f32],
-  ) extends SiteIdHostnames with SiteLimitsMultipliers {
+  ) extends SiteIdHostnames with SiteLimitsMultipliers with SiteTrait {
 
   // Reqiure at most 1 canonical host.
   //require((0 /: hosts)(_ + (if (_.isCanonical) 1 else 0)) <= 1)
 
   def canonicalHostname: Option[Hostname] = hostnames.find(_.role == Hostname.RoleCanonical)
   def canonicalHostnameStr: Option[String] = canonicalHostname.map(_.hostname)
 
   def allHostnames: Seq[St] = hostnames.map(_.hostname)
 
-  def isTestSite: Bo = id <= MaxTestSiteId
-
   def brief: SiteBrief =
     SiteBrief(id, pubId, canonicalHostname.map(_.hostname), status,
           featureFlags = featureFlags)
 }
 
diff --git a/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala b/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala
index 336f1bbf7..240a0737c 100644
--- a/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala
+++ b/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala
@@ -448,10 +448,19 @@ trait SiteTransaction {   RENAME // to SiteTx — already started with a type Si
   def insertGuest(guest: Guest): Unit   // should be: GuestDetailed
 
   def nextMemberId: UserId
   def insertMember(user: UserInclDetails): Unit
 
+
+  def loadSession(part1Maybe2Or3: Opt[St] = None, part4HttpOnly: Opt[St] = None,
+        maybeActiveOnly: Bo = false): Opt[TySessionInDbMaybeBad]
+  def loadOneOrTwoSessions(part1Maybe2Or3: Opt[St], part4HttpOnly: Opt[St],
+        maybeActiveOnly: Bo): ImmSeq[TySessionInDbMaybeBad]
+  def loadActiveSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad]
+  def insertValidSession(session: TySession): U
+  def upsertSession(session: TySessionInDbMaybeBad): U
+
   def tryLoginAsMember(loginAttempt: MemberLoginAttempt, requireVerifiedEmail: Boolean)
         : Hopefully[MemberLoginGrant]
   def loginAsGuest(loginAttempt: GuestLoginAttempt): GuestLoginResult
   def configIdtySimple(ctime: ju.Date, emailAddr: String, emailNotfPrefs: EmailNotfPrefs): Unit
 
diff --git a/appsv/model/src/main/scala/com/debiki/core/SystemTransaction.scala b/appsv/model/src/main/scala/com/debiki/core/SystemTransaction.scala
index b5f34944a..e733b3b87 100644
--- a/appsv/model/src/main/scala/com/debiki/core/SystemTransaction.scala
+++ b/appsv/model/src/main/scala/com/debiki/core/SystemTransaction.scala
@@ -107,10 +107,11 @@ trait SystemTransaction {  RENAME // to SysTx, started already
   def loadStuffToSpamCheck(limit: Int): immutable.Seq[SpamCheckTask]
   def loadMisclassifiedSpamCheckTasks(limit: Int): immutable.Seq[SpamCheckTask]
 
   // ----- The janitor: Old stuff deletion
 
+  def deletePersonalDataFromOldSessions(): U
   def deletePersonalDataFromOldAuditLogEntries(): Unit
   def deletePersonalDataFromOldSpamCheckTasks(): Unit
   def deleteOldUnusedUploads(): Unit = { /* ... later ... */ }
 
   // ----- The janitor: Review decisions
diff --git a/appsv/model/src/main/scala/com/debiki/core/TySession.scala b/appsv/model/src/main/scala/com/debiki/core/TySession.scala
new file mode 100644
index 000000000..81ed91d88
--- /dev/null
+++ b/appsv/model/src/main/scala/com/debiki/core/TySession.scala
@@ -0,0 +1,234 @@
+package com.debiki.core
+
+import com.debiki.core.Prelude._
+import play.api.libs.json.JsObject
+
+
+/** The session id is split into 5 parts in 3 cookies: a not-HttpOnly cookie,
+  * a HttpOnly and a HttpOnly SameSite=Strict cookie.  [cookie_theory]
+  *
+  * Each one of the 5 parts is, entropy wise, strong enough, on its own.
+  * To do more "important" things, more parts are needed. Whilst
+  * the first parts, not-HttpOnly, make Talkyard work also in blog comments iframes
+  * where cookies tend to not work.
+  *
+  * If expiredAt or deletedAt is set, the session cannot be used.
+  * A session can be both expired and deleted (if it gets deleted just when it expires).
+  *
+  * @param patId
+  * @param createdAt
+  * @param deletedAt
+  * @param expiredAt — updated lazily, on use. So, even if the
+  *   current time is past the createdAt + expiration time, expiredAt might be unset
+  *   — but when pat tries to use the session again, the server will notice it has
+  *   expired, and update expiredAt, thereby terminating the session.
+  * @param version
+  * @param startIp — where the user was, when hen started the session.
+  * @param startBrowserId
+  * @param startHeaders — some relevant headers from the request that created the session.
+  * @param part1CompId — comparation id, not really secret.
+  * @param part2HashForEmbgStorage
+  * @param part3HashForDirJs
+  * @param part4HashHttpOnly
+  * @param part5HashStrict
+  */
+case class TySessionInDbMaybeBad(
+  patId: PatId,
+  createdAt: When,
+  deletedAt: Opt[When],
+  expiredAt: Opt[When],
+  version: i32,
+  startIp: Opt[IpAdr],
+  startBrowserId: Opt[BrowserIdSt],
+  startHeaders: JsObject,
+  part1CompId: St,
+  part2HashForEmbgStorage: Array[i8],
+  part3HashForDirJs: Array[i8],
+  part4HashHttpOnly: Array[i8],
+  part5HashStrict: Array[i8]) {
+
+  def isValidNow(now: When, expireIdleAfterMins: i32): Bo =
+    wasValidJustRecently && !expiresNow(now, expireIdleAfterMins = expireIdleAfterMins)
+
+  def wasValidJustRecently: Bo = !isDeleted && !hasExpired
+
+  def isDeleted: Bo = deletedAt.isDefined
+  def hasExpired: Bo = expiredAt.isDefined
+
+  def expiresNow(now: When, expireIdleAfterMins: i32): Bo = {
+    val expiresAt = createdAt.millis + expireIdleAfterMins * MillisPerMinute
+    expiresAt <= now.millis
+  }
+
+
+  /// We lookup the session by part 1, and part 2 is required — so 1 and 2 are never absent.
+  def copyAsValid(part2: St, part3: Opt[St], part4: Opt[St], part5: Opt[St])
+          : TySession = {
+    dieIf(expiredAt.isDefined || deletedAt.isDefined, "TyESESS0VALID")
+    TySession(
+          patId = patId,
+          createdAt = createdAt,
+          version = version,
+          startIp = startIp,
+          startBrowserId = startBrowserId,
+          startHeaders = startHeaders,
+          part1CompId = part1CompId,
+          part2ForEmbgStorage = part2,
+          part2Hash = part2HashForEmbgStorage,
+          part3ForDirJs = part3,
+          part3Hash = part3HashForDirJs,
+          part4HttpOnly = part4,
+          part4Hash = part4HashHttpOnly,
+          part5Strict = part5,
+          part5Hash = part5HashStrict)
+  }
+}
+
+
+/** Later, more fields, see:
+  * https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-content-or-value
+  *
+  * @param patId
+  * @param createdAt
+  * @param version
+  * @param part1CompId
+  * @param part2ForEmbgStorage
+  * @param part2Hash
+  * @param part3ForDirJs
+  * @param part3Hash
+  * @param part4HttpOnly
+  * @param part4Hash
+  * @param part5Strict
+  * @param part5Hash
+  * @param isApiCall COULD split TySession into ClientSession and SingleApiReqSession maybe?
+  */
+case class TySession(
+  patId: PatId,
+  createdAt: When,
+  version: i32,
+  startIp: Opt[IpAdr],
+  startBrowserId: Opt[BrowserIdSt],
+  startHeaders: JsObject,
+  part1CompId: St,
+  part2ForEmbgStorage: St,
+  part2Hash: Array[i8],
+  part3ForDirJs: Opt[St],
+  part3Hash: Array[i8],
+  part4HttpOnly: Opt[St],
+  part4Hash: Array[i8],
+  part5Strict: Opt[St],
+  part5Hash: Array[i8],
+  isApiCall: Bo = false) {
+
+  import TySession._
+  require(version == 1, "TyE30MFEW25MMR")
+
+  require(jsObjectSize(startHeaders) < MaxSessionHeadersSize, "TyE4MW2AP7J")
+
+  require(part1CompId.length  == SidLengthCharsPart1, s"Len pt 1: ${part1CompId.length}")
+
+  require(part2Hash.length == SidHashLengthBytes, s"Len pt 2 hash: ${part2Hash.length}")
+  require(part2ForEmbgStorage.length == SidLengthCharsPart2,
+        s"Len pt 2: ${part2ForEmbgStorage.length}")
+
+  require(part3Hash.length == SidHashLengthBytes, s"Len pt 3 hash: ${part3Hash.length}")
+  require(part3ForDirJs.forall(_.length == SidLengthCharsPart3),
+        s"Len pt 3: ${part3ForDirJs.map(_.length)}")
+
+  require(part4Hash.length == SidHashLengthBytes, s"Len pt 4 hash: ${part4Hash.length}")
+  require(part4HttpOnly.forall(_.length == SidLengthCharsPart4),
+        s"Len pt 4: ${part4HttpOnly.map(_.length)}")
+
+  require(part5Hash.length == SidHashLengthBytes, s"Len pt 5 hash: ${part5Hash.length}")
+  require(part5Strict.forall(_.length == SidLengthCharsPart5),
+        s"Len pt 5: ${part5Strict.map(_.length)}")
+
+  if (com.debiki.core.isDevOrTest) {
+    import com.debiki.core.Prelude.{hashSha512FirstHalf32Bytes => hash}
+    dieIf(!hash(part2ForEmbgStorage).sameElements(part2Hash), "TyEBADHASH02")
+    dieIf(part3ForDirJs.exists(!hash(_).sameElements(part3Hash)), "TyEBADHASH03")
+    dieIf(part4HttpOnly.exists(!hash(_).sameElements(part4Hash)), "TyEBADHASH04")
+    dieIf(part5Strict.exists(!hash(_).sameElements(part5Hash)), "TyEBADHASH05")
+  }
+
+  def part1And2: St = part1CompId + part2ForEmbgStorage
+
+  def part3Absent: Bo = part3ForDirJs.isEmpty
+  def part4Absent: Bo = part4HttpOnly.isEmpty
+  def part4Present: Bo = !part4Absent
+  def part5Absent: Bo = part5Strict.isEmpty
+}
+
+
+
+object TySession {
+
+  val CurVersion = 1
+
+  // A random Base64 char is 6 bits entropy (with a good rand gen).
+  val SidEntropyPerChar: i32 = 6
+  val SidCharsetBase: i32 = 64
+
+  // These long session ids might seem like a bit overkill — OWASP writes that 64 bits
+  // entropy is enough, that's about 11 chars in Base64.  [sid_part1]
+  // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-entropy
+  //
+  // However, parts 1, 2 are for embedded comments, and for optionally saving in
+  // the localStorage of embedding pages (e.g. blog posts) which is more risky.
+  // If indeed parts 1 and 2 get compromised, we can still rely on part 3 alone
+  // — it's 144 bits entropy > 64.
+  //
+  val SidLengthCharsPart1: i32 = 16
+  val SidLengthCharsPart2: i32 = 24
+  val SidLengthCharsPart3: i32 = 24
+  val SidLengthCharsPart4: i32 = 24
+  val SidLengthCharsPart5: i32 = 16
+
+  val SidLengthCharsPart12: i32 =
+      SidLengthCharsPart1 +
+      SidLengthCharsPart2
+
+  val SidLengthCharsPart123: i32 =
+      SidLengthCharsPart12 +
+      SidLengthCharsPart3
+
+  val SidLengthCharsTotal: i32 =
+        SidLengthCharsPart123 +
+        SidLengthCharsPart4 +
+        SidLengthCharsPart5
+
+  val ApiSecretPart1 = "API_SID_PT_1_pad"          // 16 chars
+  val ApiSecretPart2 = "API_SID_PT_2_pad_24_abcd"  // 24 chars
+  val ApiSecretPart12: St = ApiSecretPart1 + ApiSecretPart2
+
+  // BLAKE3 and SHA-512/256 have 256 bits output size = 32 bytes.
+  val SidHashLengthBytes = 32
+
+  // (Part 3, 4, 5 not included in an API call TySession, so the hashes
+  // can be set to whatever. But part 2 is always included; the real hash is needed.)
+  val DummyHash: Array[i8] = Array.fill(SidHashLengthBytes)(0.asInstanceOf[i8])
+  val DummyHashPart2: Array[i8] = hashSha512FirstHalf32Bytes(ApiSecretPart2)
+
+  val MaxSessionHeadersSize = 1000
+
+  def singleApiCallSession(asPatId: PatId): TySession =
+    TySession(
+          patId = asPatId,
+          // Maybe use now() instead? Shouldn't ever matter.
+          createdAt = When.Genesis,
+          version = CurVersion,
+          startIp = None,
+          startBrowserId = None,
+          startHeaders = JsObject.empty,
+          part1CompId = ApiSecretPart1,
+          part2ForEmbgStorage = ApiSecretPart2,
+          part2Hash = DummyHashPart2,
+          part3ForDirJs = None,
+          part3Hash = DummyHash,
+          part4HttpOnly = None,
+          part4Hash = DummyHash,
+          part5Strict = None,
+          part5Hash = DummyHash,
+          isApiCall = true)
+
+}
diff --git a/appsv/model/src/main/scala/com/debiki/core/package.scala b/appsv/model/src/main/scala/com/debiki/core/package.scala
index 2de336f00..7d6200b5e 100644
--- a/appsv/model/src/main/scala/com/debiki/core/package.scala
+++ b/appsv/model/src/main/scala/com/debiki/core/package.scala
@@ -97,10 +97,11 @@ package object core {
 
 
   def isDevOrTest: Bo = Prelude.isDevOrTest
   def isProd: Bo = Prelude.isProd
 
+  type SidSt = St   // [Scala_3] opaque type
   type SignOnId = St   // [Scala_3] opaque type   change to SsoId?
   type SsoId = SignOnId   // [Scala_3] opaque type   change to SsoId?
 
   type ParsedEmail = St   // [Scala_3] opaque type
 
@@ -664,10 +665,12 @@ package object core {
 
 
   type ReqrId = Who // RENAME to ReqrIds? (with an ...s)
                     // ... because is more than one id (user id, ip, bowser id cookie, etc)
 
+  type BrowserIdSt = St  // [Scala_3] opaque type
+
   RENAME // to ReqrId? = "Requester id" and that's what it is: the user id plus hens browser id data.
   // I find "who" being confusing as to whom it refers to.
   case class Who(id: UserId, browserIdData: BrowserIdData) {
     def ip: String = browserIdData.ip
     def idCookie: Option[String] = browserIdData.idCookie
@@ -1615,7 +1618,8 @@ package object core {
   def VENDOR_THIS = ()
   def DELETE_DEPENDENCY = ()
 
   def GRAPH_DATABASE = () // Some queries are inefficient and require lots of code, when using a
                           // relational database — but are simple and fast, with a graph database.
+  def CHECK_AUTHN_STRENGTH = ()
 }
 
diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/CreateSiteSystemDaoMixin.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/CreateSiteSystemDaoMixin.scala
index 4f8d6a816..f0a5ca30c 100644
--- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/CreateSiteSystemDaoMixin.scala
+++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/CreateSiteSystemDaoMixin.scala
@@ -224,10 +224,11 @@ trait CreateSiteSystemDaoMixin extends SystemTransaction {  // RENAME to SystemS
       delete from user_visit_stats3 where site_id = ?
       delete from user_stats3 where site_id = ?
       delete from usernames3 where site_id = ?
       delete from user_emails3 where site_id = ?
       delete from group_participants3 where site_id = ?
+      delete from sessions_t where site_id_c = ?
       delete from users3 where site_id = ?
       """).trim.split("\n").toBuffer
 
     if (keepHostname) {
       // Mark hostnames as deleted, but don't forget them.
diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/Rdb.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/Rdb.scala
index 044cfa863..87af3ba6c 100644
--- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/Rdb.scala
+++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/Rdb.scala
@@ -115,10 +115,17 @@ object Rdb {
 
   implicit class PimpOptionWithNullJsValue(opt: Option[play.api.libs.json.JsValue]) {
     def orNullJson: AnyRef = opt.getOrElse(Null(js.Types.OTHER))
   }
 
+  implicit class PimpJsObjWithNullIfEmpty(jsOb: JsObject) {
+    def orNullIfEmpty: AnyRef = {
+      if (jsOb.value.isEmpty) Null(js.Types.OTHER)
+      else jsOb
+    }
+  }
+
   /*
   implicit class PimpOptionWithNullArray(opt: Option[ ? ]) {
     def orNullArray: AnyRef = opt.getOrElse(Null(js.Types.ARRAY))
   }*/
 
@@ -328,10 +335,20 @@ object Rdb {
     val timestamp = rs.getTimestamp(column, calendarUtcTimeZone)
     if (timestamp eq null) None
     else Some(When.fromMillis(timestamp.getTime))
   }
 
+  def getByteArray(rs: js.ResultSet, column: St): Array[i8] = {
+    val bytes = rs.getBytes(column)
+    dieIf(bytes eq null, "TyERSNULLBYTES", s"Column $column is null, should be a bytea")
+    bytes
+  }
+
+  def getOptByteArray(rs: js.ResultSet, column: St): Opt[Array[i8]] = {
+    Opt(rs.getBytes(column))
+  }
+
   def getOptArrayOfStrings(rs: js.ResultSet, column: String): Option[immutable.Seq[String]] = {
     val sqlArray: js.Array = rs.getArray(column)
     if (sqlArray eq null) return None
     val javaArray = sqlArray.getArray.asInstanceOf[Array[String]]
     Some(javaArray.to[Vector])
diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSiteTransaction.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSiteTransaction.scala
index f1c72212c..8ba674454 100644
--- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSiteTransaction.scala
+++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSiteTransaction.scala
@@ -48,10 +48,11 @@ class RdbSiteTransaction(var siteId: SiteId, val daoFactory: RdbDaoFactory, val
   with LinksSiteTxMixin
   with CategoriesSiteDaoMixin
   with SearchSiteDaoMixin
   with SpamCheckQueueDaoMixin
   with AuthnSiteTxMixin
+  with SessionsRdbMixin
   with UserSiteDaoMixin
   with EmailAddressesSiteDaoMixin
   with UsernamesSiteDaoMixin
   with LoginSiteDaoMixin
   with PostsReadStatsSiteDaoMixin
diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSystemTransaction.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSystemTransaction.scala
index ba2ccf89f..ea345e5a9 100644
--- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSystemTransaction.scala
+++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSystemTransaction.scala
@@ -893,10 +893,41 @@ class RdbSystemTransaction(
 
 
   val SomeMonthsAgo = 5
   val SomeYearsAgo = 5
 
+  def deletePersonalDataFromOldSessions(): U = {
+    TESTS_MISSING
+    PRIVACY; COULD // make x months below configurable
+    /*
+    val deleteABitStatement = s"""
+          update sessions_t set
+            start_ip_c = start_ip_c & inet '255.255.255.0',
+            start_headers_c = null
+            -- start_browser_id_c = hash(..)
+            forgotten_c = 1
+          where
+            forgotten_c = 0 and                    -- needs new col
+            least(deleted_at_c, expired_at_c) < ?  -- needs ix  [sess_deleted_ix]
+          """
+    runUpdate(deleteABitStatement, List(now.minusMonths(SomeMonthsAgo).asTimestamp))
+
+    val deleteMoreStatement = s"""
+          update sessions_t set
+            start_ip_c = start_ip_c & inet '255.255.0.0',
+            start_headers_c = null,
+            start_browser_id_c = null,
+            forgotten_c = 2
+          where
+            forgotten_c = 1 and                    -- needs new col
+            least(deleted_at_c, expired_at_c) < ?  -- needs ix
+          """
+    runUpdate(deleteMoreStatement, List(now.minusMonths(SomeYearsAgo).asTimestamp))
+    */
+  }
+
+
   def deletePersonalDataFromOldAuditLogEntries() {
     TESTS_MISSING
 
     // Forget the last octet fairly soon, so won't be possible to identify a
     // unique individial or household via the ip address. But so the ip can still
@@ -918,10 +949,11 @@ class RdbSystemTransaction(
         forgotten = 1
       where
         forgotten = 0 and
         done_at < ?
       """
+
     runUpdate(deleteABitStatement, List(now.minusMonths(SomeMonthsAgo).asTimestamp))
 
     PRIVACY; COULD // make x years below configurable
     // Now no need to remember region and city any longer. (But remember which country.)
     val deleteMoreStatement = s"""
@@ -1047,10 +1079,11 @@ class RdbSystemTransaction(
       delete from user_visit_stats3
       delete from user_stats3
       delete from usernames3
       delete from user_emails3
       delete from group_participants3
+      delete from sessions_t
       delete from users3
       delete from hosts3
       delete from sites3
       alter sequence DW1_TENANTS_ID restart
       """.trim.split("\n") foreach { runUpdate(_) }
diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/SessionsRdbMixin.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/SessionsRdbMixin.scala
new file mode 100644
index 000000000..c7ffe1740
--- /dev/null
+++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/SessionsRdbMixin.scala
@@ -0,0 +1,234 @@
+/**
+ * Copyright (c) 2021 Kaj Magnus Lindberg
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package com.debiki.dao.rdb
+
+import com.debiki.core._
+import com.debiki.core.Prelude._
+import Rdb._
+import RdbUtil._
+import java.sql.{ResultSet => j_ResultSet, SQLException => j_SQLException}
+import collection.{mutable => mut}
+
+
+
+/** Manages user sessions. But why are sessions stored in Postgres, not Redis?
+  * For security reasons. It's good to be able to:
+  * - Look up all current sessions by a certain user.
+  * - Look up all sessions by ip addr.
+  * - List sessions by time and user / ip.
+  * - Remember if a session's ip address changes (could be suspicious — is there
+  *   enough time for the person to travel to the new location?).
+  * - Delete some or all of a user's session, and remember who did this and when.
+  * - Delete all posts made by a session — in case someone got pwned.
+  *
+  * All that would be doable in Redis, but it's simpler, in Postgres. And,
+  * combined with an app server in-process mem cache, this approach can also be made
+  * faster than Redis (which is an out of process mem cache, slower).
+  *
+  * Maybe some time later, sessions will need to be temporarily buffered in Redis
+  * or elsewhere somehow, and only written to Postgres every once in a while
+  * (except for when logging out — that'd get persisted immediately).
+  * Or even some other type of storage.  But that's in the distant future.
+  */
+trait SessionsRdbMixin extends SiteTransaction {
+  self: RdbSiteTransaction =>
+
+
+    /// This loads also sessions that have expired, but whose expired_at_c
+    /// column hasn't been updated yet. — That's why there's "Maybe" in the
+    /// val name — sessions that are *maybe* still active.
+    private val AndMaybeActiveOnlySql =
+      "and deleted_at_c is null and expired_at_c is null"
+
+  def loadSession(part1Maybe2Or3: Opt[St] = None, part4HttpOnly: Opt[St] = None,
+        maybeActiveOnly: Bo = false)
+        : Opt[TySessionInDbMaybeBad] = {
+    dieIf(part1Maybe2Or3.isDefined == part4HttpOnly.isDefined, "TyE50MG24SMP")
+    val part1OrHash4 = part4HttpOnly.map(hashSha512FirstHalf32Bytes) getOrElse {
+      // Compare with part 1 only (not 2 or 3).
+      part1Maybe2Or3.getOrDie("TyE603MWEG657") take TySession.SidLengthCharsPart1
+    }
+
+    val andMaybeActiveOnlySql =
+          if (maybeActiveOnly) AndMaybeActiveOnlySql else ""
+
+    // Test, part 4: sso-test  TyT4ABKRW0268.TyTESESS123GONE
+    // Test, part 1: All the time, whenever logged in.
+
+    val colName = if (part1Maybe2Or3.isDefined) "part_1_comp_id_c" else "hash_4_http_only_c"
+    val query = s"""
+          select * from sessions_t
+          where site_id_c = ?
+            and $colName = ?
+            $andMaybeActiveOnlySql """
+    runQueryFindOneOrNone(query, List(siteId.asAnyRef, part1OrHash4), parseSession)
+  }
+
+
+  def loadOneOrTwoSessions(part1Maybe2Or3: Opt[St], part4HttpOnly: Opt[St],
+        maybeActiveOnly: Bo = false): ImmSeq[TySessionInDbMaybeBad] = {
+    if (part1Maybe2Or3.isEmpty && part4HttpOnly.isEmpty)
+      return Vec.empty
+
+    val values = MutArrBuf[AnyRef]()
+    values.append(siteId.asAnyRef)
+
+    val partOneEq = part1Maybe2Or3 map { part1Etc =>
+      // Skip part 2 and 3.
+      values.append(part1Etc take TySession.SidLengthCharsPart1)
+      "part_1_comp_id_c = ?"
+    } getOrElse ""
+
+    val or =
+          if (part1Maybe2Or3.isEmpty || part4HttpOnly.isEmpty) ""
+          else "or"
+
+    val partFourEq = part4HttpOnly map { part4 =>
+      values.append(hashSha512FirstHalf32Bytes(part4))
+      "hash_4_http_only_c = ?"
+    } getOrElse ""
+
+    val andMaybeActiveOnlySql =
+          if (maybeActiveOnly) AndMaybeActiveOnlySql else ""
+
+    val query = s"""
+          select * from sessions_t
+          where site_id_c = ? and ($partOneEq $or $partFourEq) $andMaybeActiveOnlySql"""
+
+    runQueryFindMany(query, values.toList, parseSession)
+  }
+
+
+  def loadActiveSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad] = {
+    val query = s"""
+          -- ix: sessions_i_patid_createdat_active
+          select * from sessions_t
+          where site_id_c = ?
+            and pat_id_c = ?
+            and deleted_at_c is null
+            and expired_at_c is null
+          order by created_at_c desc  """
+    runQueryFindMany(query, List(siteId.asAnyRef, patId.asAnyRef), parseSession)
+  }
+
+
+  def insertValidSession(session: TySession): U = {
+    dieIf(session.part1CompId == TySession.ApiSecretPart1, "TyE7P02MRED1")
+    dieIf(session.part2Hash sameElements TySession.DummyHashPart2, "TyE7P02MRED2")
+
+    val statement = s"""
+          insert into sessions_t (
+              site_id_c,
+              pat_id_c,
+              created_at_c,
+              deleted_at_c,
+              expired_at_c,
+              version_c,
+              start_ip_c,
+              start_headers_c,
+              start_browser_id_c,
+              part_1_comp_id_c,
+              hash_2_for_embg_storage_c,
+              hash_3_for_dir_js_c,
+              hash_4_http_only_c,
+              hash_5_strict_c)
+          values (?, ?, ?, null, null, ?, ?::inet, ?, ?, ?, ?, ?, ?, ?)  """
+
+    val values = List(
+          siteId.asAnyRef,
+          session.patId.asAnyRef,
+          session.createdAt.asTimestamp,
+          session.version.asAnyRef,
+          session.startIp.orNullVarchar,
+          session.startHeaders.orNullIfEmpty,
+          session.startBrowserId.orNullVarchar,
+          session.part1CompId,
+          session.part2Hash,
+          session.part3Hash,
+          session.part4Hash,
+          session.part5Hash)
+
+    runUpdateSingleRow(statement, values)
+  }
+
+
+  def upsertSession(session: TySessionInDbMaybeBad): U = {
+    dieIf(session.part1CompId == TySession.ApiSecretPart1, "TyE7P02MRED3")
+    dieIf(session.part2HashForEmbgStorage sameElements TySession.DummyHashPart2, "TyE7P02MRED4")
+
+    // Inserting expired sessions can be useful if restoring from a backup.
+    val statement = """
+          insert into sessions_t (
+              site_id_c,
+              pat_id_c,
+              created_at_c,
+              deleted_at_c,
+              expired_at_c,
+              version_c,
+              start_ip_c,
+              start_headers_c,
+              start_browser_id_c,
+              part_1_comp_id_c,
+              hash_2_for_embg_storage_c,
+              hash_3_for_dir_js_c,
+              hash_4_http_only_c,
+              hash_5_strict_c)
+          values (?, ?, ?, ?, ?, ?, ?::inet, ?, ?, ?, ?, ?, ?, ?)
+          on conflict (site_id_c, pat_id_c, created_at_c)   -- pk
+          do update set
+              deleted_at_c = least(sessions_t.deleted_at_c, excluded.deleted_at_c),
+              expired_at_c = least(sessions_t.expired_at_c, excluded.expired_at_c)  """
+
+    val values = List(
+          siteId.asAnyRef,
+          session.patId.asAnyRef,
+          session.createdAt.asTimestamp,
+          session.deletedAt.orNullTimestamp,
+          session.expiredAt.orNullTimestamp,
+          session.version.asAnyRef,
+          session.startIp.orNullVarchar,
+          session.startHeaders.orNullIfEmpty,
+          session.startBrowserId.noneIfBlank.orNullVarchar,
+          session.part1CompId,
+          session.part2HashForEmbgStorage,
+          session.part3HashForDirJs,
+          session.part4HashHttpOnly,
+          session.part5HashStrict)
+
+    runUpdateSingleRow(statement, values)
+  }
+
+
+  private def parseSession(rs: j_ResultSet): TySessionInDbMaybeBad = {
+    TySessionInDbMaybeBad(
+          patId =  getInt32(rs, "pat_id_c"),
+          createdAt = getWhen(rs, "created_at_c"),
+          deletedAt = getOptWhen(rs, "deleted_at_c"),
+          expiredAt = getOptWhen(rs, "expired_at_c"),
+          version = getInt(rs, "version_c"),
+          startIp = getOptString(rs, "start_ip_c"),
+          startHeaders = getOptJsObject(rs, "start_headers_c") getOrElse JsEmptyObj2,
+          startBrowserId = getOptString(rs, "start_browser_id_c"),
+          part1CompId = getString(rs, "part_1_comp_id_c"),
+          part2HashForEmbgStorage = getByteArray(rs, "hash_2_for_embg_storage_c"),
+          part3HashForDirJs = getByteArray(rs, "hash_3_for_dir_js_c"),
+          part4HashHttpOnly = getByteArray(rs, "hash_4_http_only_c"),
+          part5HashStrict = getByteArray(rs, "hash_5_strict_c"))
+  }
+
+}
diff --git a/appsv/server/controllers/DebugTestController.scala b/appsv/server/controllers/DebugTestController.scala
index 53c1c422d..b272a5753 100644
--- a/appsv/server/controllers/DebugTestController.scala
+++ b/appsv/server/controllers/DebugTestController.scala
@@ -39,10 +39,11 @@ import scala.concurrent.duration._
 import scala.concurrent.{Await, Future}
 import scala.concurrent.Future._
 import scala.util.Try
 import talkyard.server.TyLogging
 import talkyard.server.JsX._
+import talkyard.server.authn.MinAuthnStrength
 
 
 /** Intended for troubleshooting, via the browser, and helps running End-to-End tests.
   */
 class DebugTestController @Inject()(cc: ControllerComponents, edContext: EdContext)
@@ -53,11 +54,12 @@ class DebugTestController @Inject()(cc: ControllerComponents, edContext: EdConte
 
 
   /** If a JS error happens in the browser, it'll post the error message to this
     * endpoint, which logs it, so we'll get to know about client side errors.
     */
-  def logBrowserErrors: Action[JsValue] = PostJsonAction(RateLimits.BrowserError, maxBytes = 10000) {
+  def logBrowserErrors: Action[JsValue] = PostJsonAction(
+        RateLimits.BrowserError, MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 10000) {
         request =>
     val allErrorMessages = request.body.as[Seq[String]]
     // If there are super many errors, perhaps all of them is the same error. Don't log too many.
     val firstErrors = allErrorMessages take 20
     firstErrors foreach { message =>
@@ -296,19 +298,20 @@ class DebugTestController @Inject()(cc: ControllerComponents, edContext: EdConte
 
   def deleteRedisKey: Action[JsValue] = PostJsonAction(RateLimits.BrowserError, maxBytes = 100) {
         request =>
     throwForbiddenIf(globals.isProd, "TyE502KUJ5",
         "I only do this, in Prod mode, when an odd number of " +
-          "Phoenix birds sleep at my fireplace")
+          "Phoenix birds sleep at my fireplace, and more than one")
     val key = (request.body \ "key").as[String]
     context.globals.redisClient.del(key)
     Ok
   }
 
 
-  def skipRateLimitsForThisSite: Action[JsValue] =
-        PostJsonAction(RateLimits.BrowserError, maxBytes = 150) { request =>
+  def skipRateLimitsForThisSite: Action[JsValue] = PostJsonAction(
+        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 150) {
+            request =>
     val okE2ePassword = context.security.hasOkE2eTestPassword(request.underlying)
     throwForbiddenIf(globals.isProd && !okE2ePassword,
       "TyE8WTHFJ25", "I only do this, in Prod mode, if I can see two moons from " +
         "my kitchen window and at least two of my pigeons tell me I should.")
     val anySiteId: Opt[i32] = parseOptInt32(request.body, "siteId")
@@ -330,12 +333,12 @@ class DebugTestController @Inject()(cc: ControllerComponents, edContext: EdConte
     debiki.DeadlockDetector.createDebugTestDeadlock()
     Ok("Deadlock created, current time: " + toIso8601NoT(new ju.Date)) as TEXT
   }
 
 
-  def addAdminNotice: Action[JsValue] =
-        PostJsonAction(RateLimits.BrowserError, maxBytes = 50) { request =>
+  def addAdminNotice: Action[JsValue] = PostJsonAction(
+        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 50) { request =>
     val okE2ePassword = context.security.hasOkE2eTestPassword(request.underlying)
     throwForbiddenIf(globals.isProd && !okE2ePassword, "TyE60MRGP35", "E2e pwd missing")
     import request.body
     val siteId = (body \ "siteId").as[SiteId]
     val noticeId = (body \ "noticeId").as[NoticeId]
diff --git a/appsv/server/controllers/DraftsController.scala b/appsv/server/controllers/DraftsController.scala
index 730973ff9..2db02f5a2 100644
--- a/appsv/server/controllers/DraftsController.scala
+++ b/appsv/server/controllers/DraftsController.scala
@@ -25,32 +25,35 @@ import ed.server.{EdContext, EdController}
 import ed.server.auth.Authz
 import ed.server.http._
 import javax.inject.Inject
 import play.api.libs.json._
 import play.api.mvc._
-import scala.util.Try
 import scala.collection.immutable
 import talkyard.server.JsX.JsDraft
+import talkyard.server.authn.MinAuthnStrength
+
 
 
 class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
   extends EdController(cc, edContext) {
 
   import context.globals
   import context.security.{throwNoUnless, throwIndistinguishableNotFound}
 
 
-  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = MaxPostSize) {
+  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
         request: JsonPostRequest =>
     upsertDraftImpl(request.body, request)
   }
 
 
   /** In the browser, navigator.sendBeacon insists on sending plain text. So need this text handler.
     */
   def upsertDraftBeacon: Action[String] = PostTextAction(
-        RateLimits.DraftSomething, maxBytes = MaxPostSize) { request =>
+        RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { request =>
     val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD]
     val json = Json.parse(bodyXsrfTokenRemoved)
     upsertDraftImpl(json, request)
   }
 
@@ -77,10 +80,12 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
         requester.id = ${requester.id}, but draft.byUserId = ${draft.byUserId}""")
 
     draft = draft.copy(
       byUserId = requester.id)  // [602KDGRE20]
 
+    CHECK_AUTHN_STRENGTH
+
     // Early access control, if possible:
     //
     if (draft.isNewTopic) {
       // For now, authorize this later, when posting topic. The user can just pick another category,
       // in the categories dropdown, if current category turns out to be not allowed, when
@@ -182,17 +187,19 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
       "pagePostNrsByPostId" -> JsObject(pagePostNrs),
       "pageTitlesById" -> JsObject(pageStuffById.mapValues(stuff => JsString(stuff.title)))))
   }
 
 
-  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = 1000) {
+  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {
         request: JsonPostRequest =>
     deleteDraftsImpl(request.body, request)
   }
 
 
-  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething, maxBytes = 1000) {
+  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {
         request: ApiRequest[String] =>
     val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD]
     val json = Json.parse(bodyXsrfTokenRemoved)
     deleteDraftsImpl(json, request)
   }
@@ -200,10 +207,12 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
   private def deleteDraftsImpl(json: JsValue, request: ApiRequest[_]): Result = {
     import request.{dao, theRequester => requester}
     val byUserId = requester.id
     val draftNrs = json.as[Seq[DraftNr]]
+
+    CHECK_AUTHN_STRENGTH
     dao.readWriteTransaction { tx =>
       draftNrs.foreach(nr => tx.deleteDraft(byUserId, nr))
     }
     Ok
   }
diff --git a/appsv/server/controllers/EditController.scala b/appsv/server/controllers/EditController.scala
index dfc61a2fb..5bcbb5dff 100644
--- a/appsv/server/controllers/EditController.scala
+++ b/appsv/server/controllers/EditController.scala
@@ -30,10 +30,11 @@ import play.api.mvc.{Action, ControllerComponents}
 import play.api.libs.json._
 import EditController._
 import debiki.onebox.LinkPreviewRenderer
 import scala.concurrent.ExecutionContext
 import talkyard.server.JsX.{JsDraft, JsDraftOrNull, JsStringOrNull}
+import talkyard.server.authn.MinAuthnStrength
 import org.scalactic.{Good, Or, Bad}
 
 
 /** Edits pages and posts.
   */
@@ -45,12 +46,14 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
 
   REFACTOR // Move to DraftController?
   def loadDraftAndGuidelines(writingWhat: Int, draftType: Int, pageRole: Int,
         categoryId: Option[Int], toUserId: Option[UserId],
-        pageId: Option[String], postNr: Option[Int]): Action[Unit] =
-      GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
+        pageId: Option[String], postNr: Option[Int],
+        ): Action[Unit] =
+            GetActionRateLimited(
+              RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
 
     import request.{dao, requester}
 
     val theDraftType = DraftType.fromInt(draftType).getOrThrowBadArgument(
       "TyE5BKW2A0", "draftType")
@@ -78,11 +81,13 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
           postId = Some(thePost.id),
           pageId = pageId,
           postNr = postNr))
     }
 
-    val drafts = requester flatMap { theRequester =>
+    CHECK_AUTHN_STRENGTH
+
+    val drafts: ImmSeq[Draft] = requester flatMap { theRequester =>
       anyDraftLocator map { draftLocator =>
         dao.readOnlyTransaction { tx =>
           tx.loadDraftsByLocator(theRequester.id, draftLocator)
         }
       }
@@ -126,19 +131,21 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
   REFACTOR // Move to DraftController?
   /** Sends back a post's current CommonMark source to the browser.
     * SHOULD change to pageId + postId (not postNr)  [idnotnr]
     */
-  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] =
-        GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
+  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] = GetActionRateLimited(
+        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
 
     import request.{dao, theRequester => requester}
 
     val pageMeta = dao.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound("EdE4JBR01")
     val post = dao.loadPost(pageId, postNr) getOrElse throwIndistinguishableNotFound("EdE0DK9WY3")
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayEditPost(
       request.theUserAndLevels, dao.getOnesGroupIds(request.theUser),
       post, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta),
       inCategoriesRootLast = categoriesRootLast,
       tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXKSM2")
@@ -167,11 +174,12 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
   /** Edits posts.
     */
-  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost, maxBytes = MaxPostSize) {
+  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
         request: JsonPostRequest =>
     import request.{dao, body}
     val pageId = (body \ "pageId").as[PageId]
     val postNr = (body \ "postNr").as[PostNr] ; SHOULD // change to id, in case moved to other page [idnotnr]
     val anyPostId: Option[PostId] = (body \ "postId").asOpt[PostId]
@@ -198,10 +206,12 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
         (pageMeta, post)
     }
 
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayEditPost(
       request.theUserAndLevels, dao.getOnesGroupIds(request.theUser),
       post, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta),
       inCategoriesRootLast = categoriesRootLast,
       tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdE4JBTYE8")
@@ -230,11 +240,12 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     *   https://publish.twitter.com/oembed?url=the_url
     * and gets back Twitter tweet json that shows how to embed the tweet,
     * then creates and returns sanitized onebox html.
     */
   def fetchLinkPreview(url: St, curPageId: PageId, inline: Bo): Action[U] =
-        AsyncGetActionRateLimited(RateLimits.FetchLinkPreview) { request =>
+        AsyncGetActionRateLimited(
+            RateLimits.FetchLinkPreview, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import edContext.globals
     import request.{siteId, requesterOrUnknown}
 
     throwBadRequestIf(url.isEmpty, "TyELNPVEMPTYURL",
           "Weird URL: Empty string")
@@ -259,10 +270,12 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     // so gets done in the same way, both when 1) fetching
     // a link preview to show in the editor preview, and when 2) saving the
     // text and then later the server tries to render a link preview,
     // possibly as part of a rerender / reindex-things background job.
 
+    CHECK_AUTHN_STRENGTH
+
     val renderer = new LinkPreviewRenderer(
           globals, siteId = siteId, mayHttpFetch = true,
           requesterId = requesterOrUnknown.id)
 
     val response = renderer.fetchRenderSanitize(uri, inline = inline).transform(
@@ -289,11 +302,15 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     response
   }
 
 
   def loadPostRevisions(postId: PostId, revisionNr: String): Action[Unit] =
-        GetActionRateLimited(RateLimits.ExpensiveGetRequest) { request =>
+        GetActionRateLimited(RateLimits.ExpensiveGetRequest,
+            MinAuthnStrength.EmbeddingStorageSid12) { request =>
+
+    CHECK_AUTHN_STRENGTH
+
     val revisionNrInt =
       if (revisionNr == "LastRevision") PostRevision.LastRevisionMagicNr
       else revisionNr.toIntOption getOrElse throwBadRequest("EdE8UFMW2", "Bad revision nr")
     val (revisionsRecentFirst, usersById) =
       request.dao.loadSomeRevisionsRecentFirst(postId, revisionNrInt, atLeast = 5,
@@ -325,20 +342,23 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val patch = request.dao.editPostSettings(postId, branchSideways, request.who)
     OkSafeJson(patch) // or skip? [5GKU0234]
   }
 
 
-  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost, maxBytes = 5000) { request =>
+  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 5000) { request =>
     import request.dao
     val pageId = (request.body \ "pageId").as[PageId]
     val postNr = (request.body \ "postNr").as[PostNr]
     val repliesToo = (request.body \ "repliesToo").asOpt[Boolean] getOrElse false
 
     val action =
       if (repliesToo) PostStatusAction.DeleteTree
       else PostStatusAction.DeletePost(clearFlags = false)
 
+    CHECK_AUTHN_STRENGTH
+
     val result = dao.changePostStatus(postNr, pageId = pageId, action, userId = request.theUserId)
 
     OkSafeJson(Json.obj(
       "answerGotDeleted" -> result.answerGotDeleted,
       "deletedPost" ->
@@ -347,18 +367,21 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
         dao.jsonMaker.postToJson2(
           postNr = postNr, pageId = pageId, includeUnapproved = request.theUser.isStaff)))
   }
 
 
-  def movePost: Action[JsValue] = StaffPostJsonAction(maxBytes = 300) { request =>
+  def movePost: Action[JsValue] = StaffPostJsonAction(
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 300) { request =>
     val pageId = (request.body \ "pageId").as[PageId]   // apparently not used
     val postId = (request.body \ "postId").as[PostId]   // id not nr
     val newHost = (request.body \ "newHost").asOpt[String] // ignore for now though
     val newSiteId = (request.body \ "newSiteId").asOpt[SiteId] // ignore for now though
     val newPageId = (request.body \ "newPageId").as[PageId]
     val newParentNr = (request.body \ "newParentNr").asOpt[PostNr].getOrElse(PageParts.BodyNr)
 
+    CHECK_AUTHN_STRENGTH
+
     val (_, storePatch) = request.dao.movePostIfAuth(PagePostId(pageId, postId),
       newParent = PagePostNr(newPageId, newParentNr), moverId = request.theMember.id,
       request.theBrowserIdData)
 
     OkSafeJson(storePatch)
diff --git a/appsv/server/controllers/EmbeddedTopicsController.scala b/appsv/server/controllers/EmbeddedTopicsController.scala
index 0d958b3e3..cacdb9f14 100644
--- a/appsv/server/controllers/EmbeddedTopicsController.scala
+++ b/appsv/server/controllers/EmbeddedTopicsController.scala
@@ -47,10 +47,17 @@ class EmbeddedTopicsController @Inject()(cc: ControllerComponents, edContext: Ed
     request.dao.createForum(title, folder = "/", isForEmbCmts = true, request.who)
     Ok
   }
 
 
+  /** If in iframe, either no cookies get included, or all — so won't
+    * run into problems with just parts of the session id being present,
+    * and we can leave the authn strength at the default, MinAuthnStrength.Normal.
+    *
+    * Might need to ask the user to click a button in the iframe, triggering
+    * iOS to show a dialog where the user can let the iframe use cookies.  [ios_itp]
+    */
   def showTopic(embeddingUrl: String, discussionId: Option[AltPageId],   // [5BRW02]
           edPageId: Option[PageId], category: Option[Ref], scriptV: Opt[St]): Action[U] =
       AsyncGetActionMaybeSkipCookies(avoidCookies = true) { request =>
 
     import request.dao
@@ -122,19 +129,30 @@ class EmbeddedTopicsController @Inject()(cc: ControllerComponents, edContext: Ed
           They get created automatically by Talkyard, when someone posts the first
           comment on a blog post of yours. — If you want to give such a page
           a specific id, you can use the data-discussion-id="..." html attribute in
           the Talkyard html code snippet in your blog.""")
 
+        CHECK_AUTHN_STRENGTH // done just above. Some time later, maybe also
+        // some per category checks?
+        // Apparently Safari can pop up a dialog where the user can let the iframe
+        // use cookies, if hen interacts with the iframe. So, if a blog comments
+        // discussion requires authn to read, Ty could show a button in the iframe,
+        // like, "Click to authenticate"?
+        // Then, if clicking, Safari would ask if the iframe was allowed to use cookies,
+        // and (if answering Yes), one would get logged in directly, if there were
+        // cookies already?  [ios_itp]
+
         val (maySee, debugCode) = dao.maySeePageUseCache(pageMeta, request.requester)
         if (!maySee)
           security.throwIndistinguishableNotFound(debugCode)
 
         val pagePath = PagePath(siteId = request.siteId, folder = "/", pageId = Some(realId),
           showId = true, pageSlug = "")
 
         val pageRequest = new PageRequest[Unit](
           request.site,
+          request.anyTySession,
           sid = request.sid,
           xsrfToken = request.xsrfToken,
           browserId = request.browserId,
           user = request.user,
           pageExists = true,
diff --git a/appsv/server/controllers/FlagController.scala b/appsv/server/controllers/FlagController.scala
index dabc9e5b8..9a8945d68 100644
--- a/appsv/server/controllers/FlagController.scala
+++ b/appsv/server/controllers/FlagController.scala
@@ -23,10 +23,11 @@ import debiki.EdHttp._
 import ed.server.{EdContext, EdController}
 import javax.inject.Inject
 import play.api.mvc._
 import ed.server.auth.Authz
 import play.api.libs.json.JsValue
+import talkyard.server.authn.MinAuthnStrength
 
 
 
 // Add  flagUser() fn, so can flag user with offensive username?
 //
@@ -34,11 +35,12 @@ class FlagController @Inject()(cc: ControllerComponents, edContext: EdContext)
   extends EdController(cc, edContext) {
 
   import context.security._
 
 
-  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost, maxBytes = 2000) { request =>
+  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost,
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2000) { request =>
     import request.{body, dao}
     SHOULD // change from page-id + post-nr to post-id.
     val pageId = (body \ "pageId").as[PageId]
     val postNr = (body \ "postNr").as[PostNr]
     val typeStr = (body \ "type").as[String]
@@ -55,10 +57,12 @@ class FlagController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
     val pageMeta = dao.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound("EdE3FJB8W2")
     val post = dao.loadPost(pageId, postNr) getOrElse throwIndistinguishableNotFound("EdE5PJB2R8")
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayFlagPost(
       request.theMember, dao.getOnesGroupIds(request.theUser),
       post, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta),
       inCategoriesRootLast = categoriesRootLast,
       tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXKSM2")
diff --git a/appsv/server/controllers/ImpersonateController.scala b/appsv/server/controllers/ImpersonateController.scala
index aba11d1e2..b87775599 100644
--- a/appsv/server/controllers/ImpersonateController.scala
+++ b/appsv/server/controllers/ImpersonateController.scala
@@ -46,11 +46,11 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
 
   val MaxBecomeOldUserSeconds = 3600
   val MaxKeyAgeSeconds = 3600
 
   // This stuff is used client side too. [8AXFC0J2]
-  private val ImpersonationCookieName = "esCoImp"
+  private val ImpersonationCookieName = "esCoImp"  ; SECURITY ; SHOULD // add a HttpOnly cookie too? & delete. Both cookies required.
   private val FieldSeparator = '.'
   private val ViewAsGroupOnly = "VAO"
   private val ImpersonateRealUser = "IRU"
 
   private def redis: RedisClient = globals.redisClient
@@ -109,11 +109,11 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
 
     if (siteId != request.siteId)
       throwForbidden("EsE8YKW3", s"Wrong site id: ${request.siteId}, should go to site $siteId")
 
     // ? mark as online ?
-    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, userId)
+    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, userId)
     Redirect("/").withCookies(sidAndXsrfCookies: _*)
   }
 
 
   def impersonate(userId: UserId): Action[Unit] = AdminGetAction { request =>
@@ -142,15 +142,15 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
     // only if _both_ Nnn and Nnn2 may view them. — Not yet implemented, only view-as-stranger
     // supported right now.
     dieIf(anyUserId.isDefined && viewAsOnly, "EdE6WKT0S")
 
     val sidAndXsrfCookies = anyUserId.toList flatMap { userId =>
-      createSessionIdAndXsrfToken(request.siteId, userId)._3
+      createSessionIdAndXsrfToken(request, userId)._3
     }
 
-    val logoutCookie =
-      if (anyUserId.isEmpty) Seq(DiscardingSessionCookie)
+    val logoutCookies =
+      if (anyUserId.isEmpty) DiscardingSessionCookies
       else Nil
 
     val impCookie = makeImpersonationCookie(request.siteId, viewAsOnly, request.theUserId)
     val newCookies = impCookie :: sidAndXsrfCookies
 
@@ -158,11 +158,11 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
 
     // But don't subscribe to events for the user we'll be viewing the site as. Real
     // time events isn't the purpose of view-site-as.  The client should resubscribe
     // the requester to hens *own* notfs, once done impersonating, though.
 
-    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookie: _*)
+    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookies: _*)
   }
 
 
   private def makeImpersonationCookie(siteId: SiteId, viewAsGroupOnly: Boolean,
       currentUserId: UserId) = {
@@ -180,23 +180,26 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
     urlDecodeCookie(ImpersonationCookieName, request.underlying) match {
       case None =>
         // What's this? Clicking Stop Impersonating, but no such cookie?
         // Maybe clicking twice in different tabs? Anyway, feels
         // better to log out, so as not to accidentally stay logged in somehow.
-        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
+        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
+              wasImpersonating = true)
       case Some(cookieValue) =>
         val response =
           checkHashElseGetAgeAndUserId(cookieValue) match {
             case Bad(r) => r
             case Good((secondsAgo, oldUserId)) =>
-              // Ignore old impersonation cookies, in case they're leaked somehow.
+                // Ignore old impersonation cookies, in case they're leaked somehow.
                 if (secondsAgo > MaxBecomeOldUserSeconds || oldUserId == NoUserId) {
-                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
+                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
+                        wasImpersonating = true)
                 }
                 else {
                   // Restore the old user id.
-                  val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, oldUserId)
+                  val (_, _, sidAndXsrfCookies) =
+                        createSessionIdAndXsrfToken(request, oldUserId)
                   Ok.withCookies(sidAndXsrfCookies: _*)
                 }
           }
         response.discardingCookies(
             mvc.DiscardingCookie(ImpersonationCookieName))
diff --git a/appsv/server/controllers/InviteController.scala b/appsv/server/controllers/InviteController.scala
index 364399d93..35a2347a7 100644
--- a/appsv/server/controllers/InviteController.scala
+++ b/appsv/server/controllers/InviteController.scala
@@ -252,11 +252,11 @@ class InviteController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
     val (newUser, invite, alreadyAccepted) = dao.acceptInviteCreateUser(
       secretKey, request.theBrowserIdData)
 
     dao.pubSub.userIsActive(request.siteId, newUser.briefUser, request.theBrowserIdData)
-    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, newUser.id)
+    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, newUser.id)
     val newSessionCookies = sidAndXsrfCookies
 
     if (!alreadyAccepted) {
       // Could try to ensure this happens also if the server crashes here? [retry-after-crash]
       val settings = dao.getWholeSiteSettings()
diff --git a/appsv/server/controllers/LoginAsGuestController.scala b/appsv/server/controllers/LoginAsGuestController.scala
index 04498b289..ac04b04ce 100644
--- a/appsv/server/controllers/LoginAsGuestController.scala
+++ b/appsv/server/controllers/LoginAsGuestController.scala
@@ -93,11 +93,11 @@ class LoginAsGuestController @Inject()(cc: ControllerComponents, edContext: EdCo
         guestBrowserId = theBrowserId)
 
       val guestUser = dao.loginAsGuest(loginAttempt)
 
       val (sid, _, sidAndXsrfCookies) =
-        security.createSessionIdAndXsrfToken(request.siteId, guestUser.id)
+            security.createSessionIdAndXsrfToken(request, guestUser.id)
 
       var responseJson = Json.obj(  // ts: AuthnResponse
         "userCreatedAndLoggedIn" -> JsTrue,
         "emailVerifiedAndLoggedIn" -> JsFalse)
 
diff --git a/appsv/server/controllers/LoginController.scala b/appsv/server/controllers/LoginController.scala
index 42a00c91b..f80dbc4b6 100644
--- a/appsv/server/controllers/LoginController.scala
+++ b/appsv/server/controllers/LoginController.scala
@@ -34,11 +34,11 @@ import talkyard.server.JsX
   */
 class LoginController @Inject()(cc: ControllerComponents, edContext: EdContext)
   extends EdController(cc, edContext) {
 
   import context.globals
-  import context.security.DiscardingSessionCookie
+  import context.security.DiscardingSessionCookies
   import LoginController._
 
 
   def showLoginPage(as: Option[String], to: Option[String]): Action[Unit] = GetActionIsLogin { apiReq =>
     val path = to getOrElse "/"
@@ -120,24 +120,30 @@ class LoginController @Inject()(cc: ControllerComponents, edContext: EdContext)
       returnToUrl = returnToUrl,
       isInLoginPopup = true)) as HTML
   }
 
 
-  /** Clears login related cookies and OpenID and OpenAuth stuff, unsubscribes
-    * from any event channel.
+  /** Clears session cookies and ends the session server side too; unsubscribes
+    * from any websockets channel.
     */
   def logout(currentUrlPath: Opt[St]): Action[U] = GetActionAllowAnyone { request =>
-    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath)
+    SECURITY // optionally log out from all devices?
+    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath,
+          wasImpersonating = false)
   }
 
 
-  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St]): Result = {
+  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St],
+          wasImpersonating: Bo): Result = {
     import request.{dao, requester, siteSettings}
 
+    AUDIT_LOG // session id destruction
+
     requester foreach { theRequester =>
-      request.dao.logout(theRequester, bumpLastSeen = true)
+      request.dao.logout(theRequester, bumpLastSeen = !wasImpersonating)
     }
+    dao.terminateSessionForCurReq(request.underlying)
 
     val goToNext: Opt[St] = siteSettings.effSsoLogoutAllRedirUrl orElse {
       redirectIfMayNotSeeUrlPath flatMap { urlPath =>
         // May-not-see tested here: [TyT503KRDHJ2]. (May-see: Tested "everywhere".)
         // (For embedded comments, maySee will be true.)
@@ -160,11 +166,11 @@ class LoginController @Inject()(cc: ControllerComponents, edContext: EdContext)
                   // Then we may see this path: (if any)
                   redirectIfMayNotSeeUrlPath) getOrElse "/")
       }
 
     // Keep the xsrf cookie, so the login dialog will work.
-    response.discardingCookies(DiscardingSessionCookie)
+    response.discardingCookies(DiscardingSessionCookies: _*)
   }
 
 
   def resendSiteOwnerAddrVerifEmail: Action[Unit] =
         GetActionRateLimited(RateLimits.Login, allowAnyone = true) { request =>
diff --git a/appsv/server/controllers/LoginWithOpenAuthController.scala b/appsv/server/controllers/LoginWithOpenAuthController.scala
index 6f5b4932a..34f90bdbe 100644
--- a/appsv/server/controllers/LoginWithOpenAuthController.scala
+++ b/appsv/server/controllers/LoginWithOpenAuthController.scala
@@ -180,10 +180,11 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
   private val IsInLoginWindowCookieName = "dwCoIsInLoginWindow"
   private val IsInLoginPopupCookieName = "dwCoIsInLoginPopup"
   private val MayCreateUserCookieName = "dwCoMayCreateUser"
   private val AuthStateCookieName = "dwCoOAuth2State"
 
+  // Discard these also if logging in with username + password?  [clear_aun_cookies]
   private val CookiesToDiscardAfterLogin: Seq[DiscardingCookie] = Seq(
     ReturnToUrlCookieName,
     ReturnToSiteOriginTokenCookieName,
     ReturnToThisSiteXsrfTokenCookieName,
     AvoidCookiesCookieName,
@@ -1590,10 +1591,11 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
     // the create-user dialog is shown: showsCreateUserDialog == true). Otherwise,
     // accidentally reloading the page, results in weird errors, like the xsrf token
     // missing. But supporting page reload here requires fairly many mini fixes,
     // and maybe is marginally worse for security? since then someone else,
     // e.g. an "evil" tech support person, can ask for and reuse the url?
+    // [.clearing_cookies] [clear_aun_cookies]
     result.discardingCookies(CookiesToDiscardAfterLogin: _*)
   }
 
 
 
@@ -1603,11 +1605,11 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
   private def createCookiesAndFinishLogin(
         request: DebikiRequest[_], siteId: SiteId, member: User,
         authnState: OngoingAuthnState): Result = {
 
     request.dao.pubSub.userIsActive(request.siteId, member, request.theBrowserIdData)
-    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, member.id)
+    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, member.id)
 
     var maybeCannotUseCookies =
       request.headers.get(EdSecurity.AvoidCookiesHeaderName) is EdSecurity.Avoid
 
     def weakSessionIdOrEmpty =
@@ -1946,10 +1948,12 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
         newUserEmail = oauthDetails.emailLowercasedOrEmpty,
         authDataCacheKey = nextNonce,
         anyContinueToUrl = Some(authnState.returnToUrl)))
     }
 
+    // This not needed? These cookies, and many more, already cleared at the
+    // end of the caller, tryLoginOrShowCreateUserDialog()  [.clearing_cookies].
     result.discardingCookies(
       DiscardingSecureCookie(IsInLoginWindowCookieName),
       DiscardingSecureCookie(ReturnToUrlCookieName))
   }
 
diff --git a/appsv/server/controllers/LoginWithPasswordController.scala b/appsv/server/controllers/LoginWithPasswordController.scala
index 5c9509d53..2d8cda320 100644
--- a/appsv/server/controllers/LoginWithPasswordController.scala
+++ b/appsv/server/controllers/LoginWithPasswordController.scala
@@ -123,11 +123,12 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
           throwForbidden("TyEQMSGEX02", ex.getMessage)
       }
     }
 
     dao.pubSub.userIsActive(request.siteId, loginGrant.user, request.theBrowserIdData)
-    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
+    val (sid, _, sidAndXsrfCookies) =
+          createSessionIdAndXsrfToken(request, loginGrant.user.id)
     (sid, sidAndXsrfCookies)
   }
 
 
   def handleCreateUserDialog: Action[JsValue] = AsyncPostJsonAction(
@@ -136,11 +137,11 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
         // could sign up for SiteStatus.HiddenUnlessStaff/Admin. So, not right now.
         // Perhaps later though, if staff can be invited directly via invite emails. [5PY8FD2]
         allowAnyone = true) { request: JsonPostRequest =>
 
     // A bit dupl code. [2FKD05]
-    import request.body
+    import request.{body, dao}
 
     val fullName = (body \ "fullName").asOptStringNoneIfBlank
     val emailAddress = (body \ "email").as[String].trim
     val username = (body \ "username").as[String].trim
     val password = (body \ "password").asOpt[String] getOrElse
@@ -148,22 +149,10 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
     val anyReturnToUrl = (body \ "returnToUrl").asOpt[String]
 
     val maybeCannotUseCookies =
       request.headers.get(EdSecurity.AvoidCookiesHeaderName) is EdSecurity.Avoid
 
-    CLEAN_UP // remove daoFor, use request.dao instead. Just look in the logs that this'll
-    // work fine for sure (shouldn't be any "TyEWEIRDDAO" in the logs).
-    val dao = daoFor(request.request)
-    if (dao.siteId != request.dao.siteId) {
-      if (globals.isProd) {
-        logger.warn("Weird: dao.siteId != request.dao.siteId  [TyEWEIRDDAO]")
-      }
-      else {
-        die("TyE305AKTFWJ2", "Wrong dao, *harmmless* but why?")
-      }
-    }
-
     val siteSettings = dao.getWholeSiteSettings()
 
     val itsDisabled = "Creation of local password accounts is disabled"
     throwForbiddenIf(siteSettings.enableSso, "TyESSO0PW", itsDisabled +
           ", because Single Sign-On is enabled")
@@ -255,11 +244,12 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
           (None, Nil)
         }
         else {
           dieIf(newMember.email.isEmpty && requireVerifiedEmail, "EdE2GKF06")
           dao.pubSub.userIsActive(request.siteId, newMember, request.theBrowserIdData)
-          val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(dao.siteId, newMember.id)
+          val (sid, _, sidAndXsrfCookies) =
+                createSessionIdAndXsrfToken(request, newMember.id)
           (Some(sid), sidAndXsrfCookies)
         }
       }
       catch {
         case _: DbDao.DuplicateUsername =>
@@ -288,10 +278,13 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
       val responseJson = Json.obj(  // ts: AuthnResponse
         "userCreatedAndLoggedIn" -> JsBoolean(loginCookies.nonEmpty),
         "emailVerifiedAndLoggedIn" -> JsBoolean(emailVerifiedAt.isDefined),
         "weakSessionId" -> JsString(weakSessionId))
 
+      COULD_OPTIMIZE // Clear login related cookies? [clear_aun_cookies]
+      // Done already in the OAuth login controller; it clears these: CookiesToDiscardAfterLogin.
+
       OkSafeJson(responseJson).withCookies(loginCookies: _*)
     }
   }
 
 
@@ -350,11 +343,11 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
         // javascript on the destination page will send the one-time secret to
         // the server, and get back a session id. [306KUD244]
         (Nil, Some(returnToUrl + s"#talkyardOneTimeLoginSecret=$loginSecret"))
       }
       else {
-        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, user.id)
+        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, user.id)
         val anyReturnToUrl: Option[String] =
           if (returnToUrl.nonEmpty) Some(returnToUrl) else None
         (sidAndXsrfCookies, anyReturnToUrl)
       }
 
diff --git a/appsv/server/controllers/ReplyController.scala b/appsv/server/controllers/ReplyController.scala
index a9c8cd0da..5ded93935 100644
--- a/appsv/server/controllers/ReplyController.scala
+++ b/appsv/server/controllers/ReplyController.scala
@@ -26,10 +26,11 @@ import ed.server.auth.Authz
 import ed.server.http._
 import javax.inject.Inject
 import play.api._
 import play.api.libs.json.{JsObject, JsString, JsValue, Json}
 import play.api.mvc._
+import talkyard.server.authn.MinAuthnStrength
 
 
 /** Saves replies. Lazily creates pages for embedded discussions
   * — such pages aren't created until the very first reply is posted.
   */
@@ -37,11 +38,12 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: EdContext)
   extends EdController(cc, edContext) {
 
   import context.security.{throwNoUnless, throwIndistinguishableNotFound}
 
 
-  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply, maxBytes = MaxPostSize) {
+  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
         request: JsonPostRequest =>
     import request.{body, dao, theRequester => requester}
     val anyPageId = (body \ "pageId").asOpt[PageId]
     val anyDiscussionId = (body \ "discussionId").asOpt[AltPageId] orElse (
           body \ "altPageId").asOpt[AltPageId] ; CLEAN_UP // deprecated name [058RKTJ64] 2020-06
@@ -68,10 +70,12 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val replyToPosts = dao.loadPostsAllOrError(pageId, replyToPostNrs) getOrIfBad { missingPostNr =>
       throwNotFound(s"Post nr $missingPostNr not found", "EdEW3HPY08")
     }
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayPostReply(
       request.theUserAndLevels, dao.getOnesGroupIds(request.theUser),
       postType, pageMeta, replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta),
       inCategoriesRootLast = categoriesRootLast,
       tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)),
diff --git a/appsv/server/controllers/ResetPasswordController.scala b/appsv/server/controllers/ResetPasswordController.scala
index f67b3ce9d..5dfb2568a 100644
--- a/appsv/server/controllers/ResetPasswordController.scala
+++ b/appsv/server/controllers/ResetPasswordController.scala
@@ -211,11 +211,11 @@ class ResetPasswordController @Inject()(cc: ControllerComponents, edContext: EdC
       mayLoginAgain = false)
     request.dao.changePasswordCheckStrongEnough(loginGrant.user.id, newPassword)
 
     // Log the user in and show password changed message.
     request.dao.pubSub.userIsActive(request.siteId, loginGrant.user, request.theBrowserIdData)
-    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
+    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, loginGrant.user.id)
     val newSessionCookies = sidAndXsrfCookies
     CSP_MISSING
     Ok(views.html.resetpassword.passwordHasBeenChanged(SiteTpi(request)))
           .withCookies(newSessionCookies: _*)
   }
diff --git a/appsv/server/controllers/UserController.scala b/appsv/server/controllers/UserController.scala
index 9fb72c4ef..89ec7bafd 100644
--- a/appsv/server/controllers/UserController.scala
+++ b/appsv/server/controllers/UserController.scala
@@ -35,11 +35,11 @@ import ed.server.{EdContext, EdController}
 import ed.server.auth.Authz
 import javax.inject.Inject
 import org.scalactic.{Bad, Good}
 import talkyard.server.JsX
 import talkyard.server.JsX._
-import talkyard.server.p_Result
+import talkyard.server.authn.MinAuthnStrength
 import talkyard.server.TyLogging
 
 
 /** Handles requests related to users.
  */
@@ -102,11 +102,12 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
   /** Loads a member or group, incl details, or a guest (then there are no details).
     */
-  def loadUserAnyDetails(who: String): Action[Unit] = GetAction { request =>
+  def loadUserAnyDetails(who: St): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.{dao, requesterOrUnknown}
     // First try looking up by `who` as a  numeric user id. If won't work,
     // lookup by `who` as username instead.
 
     CLEAN_UP // make reusable   [load_pat_stats_grps]
@@ -128,10 +129,13 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
       userJson += "pubTags" -> JsArray(tags.map(JsTag))
     }
 
     val tagTypes: Seq[TagType] = dao.getTagTypesForTags(tags)
 
+    // Maybe incl less data if embedded?
+    CHECK_AUTHN_STRENGTH
+
     // Maybe? No, stats is ok to show? Could possibly add another conf val, hmm.
     /*val stats =
       if (maySeeActivity(userId, request.requester, request.dao)) anyStatsJson
       else JsNull */
     userJson += "anyUserStats" -> anyStatsJson
@@ -367,11 +371,11 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
   def downloadPersonalData(userId: UserId): Action[Unit] = GetActionRateLimited(
         RateLimits.DownloaPersonalData) { request: GetRequest =>
-      import request.{dao, theRequester => requester}
+    import request.{dao, theRequester => requester}
     throwForbiddenIf(userId != requester.id && !requester.isAdmin,
       "TyE2PKAQX8", "Cannot download someone else's data")
 
     val result = dao.readOnlyTransaction { tx =>
       val member: UserInclDetails = tx.loadTheUserInclDetails(userId)
@@ -393,10 +397,14 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
       val otherEmailAddresses =
         tx.loadUserEmailAddresses(userId).filterNot(_.emailAddress == member.primaryEmailAddress)
       val otherEmailsJson = JsArray(otherEmailAddresses.map(ea => JsString(ea.emailAddress)))
 
+      // To do: Incl old expired sessions too? Or maybe that should be a separate
+      // endpoint. [gdpr]
+      val sessions = dao.listPatsSessions(userId)
+
       val identities: Seq[Identity] = tx.loadIdentities(userId)
       val identitiesJson = JsArray(identities map {
         case oauthId: OpenAuthIdentity =>
           val details: OpenAuthDetails = oauthId.openAuthDetails
           Json.obj(
@@ -440,11 +448,12 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
         // Incl in Uploads links archieve instead?
         "avatarImageUrl" -> JsStringOrNull(member.mediumAvatar.map(request.cdnOrSiteOrigin + _.url)),
         "trustLevel" -> JsString(member.effectiveTrustLevel.toString),
         "identities" -> identitiesJson,
         "statistics" -> statsJson,
-        "browserIdDataRecentFirst" -> browserIdDataJson)
+        "browserIdDataRecentFirst" -> browserIdDataJson,
+        "sessions" -> sessions.map(s => JsSession(s, inclPart1 = false)))
     }
 
     OkSafeJson(result)
   }
 
@@ -888,11 +897,15 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
   /** If not staff, returns a summary only.
     */
-  def loadAuthorBlocks(postId: Int): Action[Unit] = GetAction { request =>
+  def loadAuthorBlocks(postId: Int): Action[Unit] = GetActionRateLimited(
+        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
+
+    CHECK_AUTHN_STRENGTH
+
     val blocks: Seq[Block] = request.dao.loadAuthorBlocks(postId)
     var json = blocksSummaryJson(blocks, request.ctime)
     if (request.user.exists(_.isStaff)) {
       json += "blocks" -> JsArray(blocks map blockToJson)
     }
@@ -952,11 +965,12 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     ViewPageController.addVolatileJsonAndPreventClickjacking2(htmlStr,
       unapprovedPostAuthorIds = Set.empty, request)
   }
 
 
-  def loadMyPageData(pageIds: St): Action[U] = GetAction { request =>
+  def loadMyPageData(pageIds: St): Action[U] = GetAction2(RateLimits.ReadsFromDb,
+        MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.dao
     QUICK; COULD_OPTIMIZE // don't use String.split('') — it sometimes creates a regex.
     // Review the whole code base. // Use Guava's Splitter instead.
     // https://guava.dev/releases/20.0/api/docs/com/google/common/base/Splitter.html
 
@@ -981,10 +995,12 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
       // Might be an embedded comment page, not yet created because no comments posted.
       // Or we might be in the signup-to-become-owner step, when creating a new site.
       return dao.jsonMaker.userNoPageToJson(request)
     }
 
+    CHECK_AUTHN_STRENGTH
+
     val pagePath = request.dao.getPagePath(pageId) getOrElse {
       // The page was apparently deleted some microseconds ago.
       return dao.jsonMaker.userNoPageToJson(request)
     }
 
@@ -993,10 +1009,11 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
       return dao.jsonMaker.userNoPageToJson(request)
 
     val pageRequest = new PageRequest(
       request.site,
       sid = request.sid,
+      anyTySession = request.anyTySession,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,
       user = request.user,
       pageExists = true,
       pagePath = pagePath,
@@ -1022,19 +1039,21 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     res
   }
 
 
   def trackReadingProgress: Action[JsValue] = PostJsonAction(RateLimits.TrackReadingActivity,
-        maxBytes = 1000) { request =>
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
     import request.{dao, theRequester}
     val readMoreResult = trackReadingProgressImpl(request, request.body)
     val result =
       if (readMoreResult.numMoreNotfsSeen == 0) JsNull
       else {
         // Posts related to some notifications were seen. Update the notifications, client side,
         // so they'll get un-highlighted, since the posts they are about, have now been seen.
 
+        CHECK_AUTHN_STRENGTH // Skip the notifications, if in embedded iframe?
+
         // dupl line [8AKBR0]
         val notfsAndCounts = dao.loadNotificationsSkipReviewTasks(
           theRequester.id, upToWhen = None, request.who, unseenFirst = true, limit = 20)
 
         // dupl code [7KABR20]
@@ -1051,11 +1070,11 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
 
   /** In the browser, navigator.sendBeacon insists on sending plain text. So need this text handler.
     */
   def trackReadingProgressText: Action[String] = PostTextAction(RateLimits.TrackReadingActivity,
-        maxBytes = 1000) { request =>
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
     val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD]
     val json = Json.parse(bodyXsrfTokenRemoved)
     trackReadingProgressImpl(request, json)
     Ok
   }
@@ -1099,10 +1118,12 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     postNrsReadAsSet.find(_ < BodyNr) map { badNr =>
       // The title and draft post nrs are < 0 and shouldn't be included here.
       throwForbidden("TyE5RKPW025", s"Bad post nr, smaller than BodyNr: $badNr")
     }
 
+    CHECK_AUTHN_STRENGTH // + may see?
+
     logger.trace(
       s"s$siteId, page $anyPageId: Post nrs read: $postNrsRead, seconds reading: $secondsReading")
 
     val now = globals.now()
     val lowPostNrsRead: Set[PostNr] = postNrsReadAsSet.filter(_ <= PageReadingProgress.MaxLowPostNr)
@@ -1186,28 +1207,28 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val notfsAndCounts = request.dao.loadNotificationsSkipReviewTasks(userId, upToWhen, request.who)
     OkSafeJson(notfsAndCounts.notfsJson)
   }
 
 
-  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
-        request =>
+  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
+        maxBytes = 200) { request =>
     request.dao.markAllNotfsAsSeen(request.theUserId)
     loadNotificationsImpl(request.theUserId, upToWhen = None, request)
   }
 
 
-  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
-        request =>
+  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
+        maxBytes = 200) { request =>
     import request.{dao, theRequesterId}
     val notfId = (request.body \ "notfId").as[NotificationId]
     dao.markNotificationAsSeen(theRequesterId, notfId)
     Ok
   }
 
 
-  def snoozeNotifications(): Action[JsValue] =
-          PostJsonAction(RateLimits.ConfigUser, 200) { request =>
+  def snoozeNotifications(): Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
+        maxBytes = 200) { request =>
     import request.{dao, theRequesterId}
     val untilWhen: Option[When] =
           (request.body \ "untilMins").as[JsValue] match {
             case JsFalse => None
             case JsNumber(whenMins) if whenMins >= 0 =>
@@ -1217,12 +1238,12 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     dao.snoozeNotifications(theRequesterId, untilWhen)
     Ok
   }
 
 
-  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, maxBytes = 500) {
-        request =>
+  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) { request =>
     import request.{dao, theRequester => requester}
     val body = request.body
     val memberId = (body \ "memberId").as[MemberId]
     val anyPageIdMaybeEmptyPage = (body \ "pageId").asOpt[PageId]
     val pagesPatCreated = (body \ "pagesPatCreated").asOpt[Bo]
@@ -1278,10 +1299,12 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
                 pagesInCategoryId = pagesInCategoryId,
                 wholeSite = wholeSite.getOrElse(false),
                 notfLevel = newNotfLevel.getOrElse(NotfLevel.DoesNotMatterHere)))
             .getOrIfFailure(ex => throwBadRequest("TyE2ABKRP0", ex.getMessage))
 
+    CHECK_AUTHN_STRENGTH
+
     if (newNotfLevel.isDefined) {
       dao.savePageNotfPrefIfAuZ(newPref, request.who)
     }
     else {
       dao.deletePageNotfPrefIfAuZ(newPref, request.who)
@@ -1350,20 +1373,23 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     Ok
   }
 
 
   SECURITY // don't allow if user listing disabled, & isn't staff [8FKU2A4]
-  def listAllUsers(usernamePrefix: String): Action[Unit] = GetAction { request =>
+  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
+  def listAllUsers(usernamePrefix: St): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     // Authorization check: Is a member? Add MemberGetAction?
     request.theMember
 
     val json = listAllUsersImpl(usernamePrefix, request)
     OkSafeJson(json)
   }
 
 
   SECURITY // user listing disabled? [8FKU2A4]
+  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
   def listMembersPubApi(usernamePrefix: String, usersOnly: Boolean)
         : Action[Unit] = GetAction { request =>
     // Allowed also if not logged in — so can use from a non-Talkyard client,
     // without any API secret.
     throwForbiddenIf(!request.siteSettings.enableApi,
@@ -1397,21 +1423,24 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
 
   /** Listing usernames on a particular page is okay, if one may see the page
     * — however, listing all usernames for the whole site, isn't always okay. [8FKU2A4]
     */
-  def listUsernames(pageId: PageId, prefix: String): Action[Unit] = GetAction { request =>
+  def listUsernames(pageId: PageId, prefix: St): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.dao
 
     val pageMeta = dao.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound("EdE4Z0B8P5")
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
     SECURITY // Later: skip authors of hidden / deleted / whisper posts.  [whispers]
     // Or if some time in the future there will be "hidden" accounts  [hdn_acts]
     // — someone who don't want strangers and new members to see hens profile —
     // then, would need to exclude those accounts here.
 
+    CHECK_AUTHN_STRENGTH  // disallow if just sid part 1+2 but not embedded page
+
     throwNoUnless(Authz.maySeePage(
       pageMeta, request.user, dao.getGroupIdsOwnFirst(request.user),
       dao.getAnyPrivateGroupTalkMembers(pageMeta), categoriesRootLast,
       tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXKSM2")
 
@@ -1582,11 +1611,11 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     throwForbiddenIf(!isOneself && !requester.isAdmin,
       "TyE7UBQP21", "Cannot delete other user")
     val anonNNN = dao.deleteUser(userId, request.who)
     val response = OkSafeJsValue(JsString(anonNNN.username))
     // Log the user out, if hen deleted hens own account.
-    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookie)
+    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookies: _*)
     else response
   }
 
 
   private def aboutMemberPrefsFromJson(json: JsValue): AboutUserPrefs = {
diff --git a/appsv/server/controllers/ViewPageController.scala b/appsv/server/controllers/ViewPageController.scala
index f755ec25a..6066c07c5 100644
--- a/appsv/server/controllers/ViewPageController.scala
+++ b/appsv/server/controllers/ViewPageController.scala
@@ -27,14 +27,14 @@ import play.api.libs.json._
 import play.api.mvc._
 import scala.concurrent.Future
 import ed.server.{EdContext, EdController, RenderedPage}
 import javax.inject.Inject
 import ViewPageController._
-import debiki.dao.{UsersOnlineStuff, NoUsersOnlineStuff}
+import debiki.dao.NoUsersOnlineStuff
 import ed.server.auth.MaySeeOrWhyNot
-import ed.server.security.EdSecurity
 import talkyard.server.authn.LoginReason
+import talkyard.server.authn.MinAuthnStrength
 import talkyard.server.JsX.JsObjOrNull
 
 
 
 /** Shows pages and individual posts.
@@ -51,10 +51,11 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
   import context.globals
   import context.security
 
 
 
+  CHECK_AUTHN_STRENGTH
   def loadPost(pageId: PageId, postNr: PostNr): Action[Unit] = GetActionAllowAnyone { request =>
     // Similar to getPageAsJsonImpl and getPageAsHtmlImpl, keep in sync. [7PKW0YZ2]
 
     val dao = request.dao
     val siteSettings = dao.getWholeSiteSettings()
@@ -67,10 +68,11 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
 
       if (siteSettings.userMustBeApproved && !request.theUser.isApprovedOrStaff)
         throwForbidden("EdE4F8WV0", "Account not approved")
     }
 
+    // & sid leveL?
     val (maySeeResult, debugCode) = dao.maySeePostUseCache(pageId, postNr, request.user)
     maySeeResult match {
       case MaySeeOrWhyNot.YesMaySee =>
         // Fine, don't throw.
       case MaySeeOrWhyNot.NopeNoPostWithThatNr =>
@@ -101,10 +103,11 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
     * and then log parsing tools will understand that the JSON requests are real page navigation
     * steps that updates the browser's URL to the page path.
     * Good for analytics and understanding what the users do at the site?
     * The SPA stuff is just an optimization.
     */
+  CHECK_AUTHN_STRENGTH
   def viewPage(path: String): Action[Unit] = AsyncGetActionAllowAnyone { request =>
     if (request.queryString.get("json").isDefined) {
       getPageAsJson(path, request)
     }
     else {
@@ -183,10 +186,11 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
     // ?? maybe: if (request.user.isEmpty)
     //  globals.strangerCounter.strangerSeen(request.siteId, request.theBrowserIdData)
 
     val pageRequest = new PageRequest[Unit](
       request.site,
+      request.anyTySession,
       sid = request.sid,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,
       user = request.user,
       pageExists = true,
@@ -240,12 +244,13 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
         Future.successful(makeLoginDialog(ex))
     }
   }
 
 
-  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits, maxBytes = 2) {
-        request =>
+  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2) { request =>
+    CHECK_AUTHN_STRENGTH
     val watchbar = request.dao.getOrCreateWatchbar(request.theUserId)
     val newWatchbar = watchbar.markPageAsSeen(pageId)
     request.dao.saveWatchbar(request.theUserId, newWatchbar)
     Ok
   }
@@ -301,13 +306,19 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
                 ("You may not access this site with this account, sorry.", "TyMNOACCESS_")
               case Some(true) =>
                 die("DwE7KEWK2", "Both not approved and approved")
             }
         }
-        var forbidden = ForbiddenResult(s"TyM0APPR_-$code", message)
-        if (logout) forbidden = forbidden.discardingCookies(security.DiscardingSessionCookie)
-        return Future.successful(forbidden)
+        var forbiddenResp = ForbiddenResult(s"TyM0APPR_-$code", message)
+        if (logout) {
+          COULD_OPTIMIZE // do these two in the same tx:
+          dao.logout(request.theReqer, bumpLastSeen = true)
+          dao.terminateSessionForCurReq(request.underlying)
+
+          forbiddenResp = forbiddenResp.discardingCookies(security.DiscardingSessionCookies: _*)
+        }
+        return Future.successful(forbiddenResp)
       }
     }
 
     val correctPagePath = dao.checkPagePath(specifiedPagePath) getOrElse {
       // The page doesn't exist.
@@ -348,10 +359,11 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
     if (request.user.isEmpty)
       globals.strangerCounter.strangerSeen(request.siteId, request.theBrowserIdData)
 
     val pageRequest = new PageRequest[Unit](
       request.site,
+      request.anyTySession,
       sid = request.sid,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,
       user = request.user,
       pageExists = true,
@@ -542,10 +554,11 @@ object ViewPageController {
       categoryId = None,
       publishDirectly = true)
 
     new DummyPageRequest(
       request.site,
+      request.anyTySession,
       sid = request.sid,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,
       user = request.user,
       pageExists = false,  // CLEAN_UP REMOVE? use pageId == EmptyPageId instead?
diff --git a/appsv/server/controllers/VoteController.scala b/appsv/server/controllers/VoteController.scala
index 006868716..b09a440e5 100644
--- a/appsv/server/controllers/VoteController.scala
+++ b/appsv/server/controllers/VoteController.scala
@@ -27,10 +27,12 @@ import ed.server.auth.Authz
 import ed.server.http._
 import javax.inject.Inject
 import play.api.libs.json._
 import play.api.mvc.{Action, ControllerComponents}
 import talkyard.server.JsX.{JsUser, JsStringOrNull}
+import talkyard.server.authn.MinAuthnStrength
+
 
 
 /** Handles votes, e.g. "I like this comment" or "this comment is faulty" votes.
  */
 class VoteController @Inject()(cc: ControllerComponents, edContext: EdContext)
@@ -44,12 +46,13 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: EdContext)
     *   postNr: 123
     *   vote: "VoteLike"      # or "VoteWrong" or "VoteBury"
     *   action: "CreateVote"  # or "DeleteVote"
     *   postIdsRead: [1, 9, 53, 82]
     */
-  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost, maxBytes = 500) {
-        request: JsonPostRequest =>
+  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost,
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) {
+          request: JsonPostRequest =>
     import request.{body, dao, theRequester => requester}
     val anyPageId = (body \ "pageId").asOpt[PageId]
 
     val anyDiscussionId = (body \ "discussionId").asOpt[AltPageId] orElse (
           body \ "altPageId").asOpt[AltPageId] ; CLEAN_UP // deprecated name [058RKTJ64] 2020-06
@@ -98,10 +101,12 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val (pageId, newEmbPage) = EmbeddedCommentsPageCreator.getOrCreatePageId(
           anyPageId = anyPageId, anyDiscussionId = anyDiscussionId,
           anyEmbeddingUrl = anyEmbeddingUrl, lazyCreatePageInCatId = lazyCreatePageInCatId,
           request)
 
+    CHECK_AUTHN_STRENGTH
+
     if (delete) {
       dao.deleteVoteIfAuZ(pageId, postNr, voteType, voterId = request.theUser.id)
     }
     else {
       dao.addVoteIfAuZ(pageId, postNr, voteType,
@@ -122,16 +127,19 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
     OkSafeJson(responseJson)
   }
 
 
-  def loadVoters(postId: PostId, voteType: Int): Action[Unit] = GetAction { request =>
+  def loadVoters(postId: PostId, voteType: Int): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.{dao, requester}
 
     val pageMeta: PageMeta = dao.getThePageMetaForPostId(postId)
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.maySeePage(
       pageMeta, requester,
       dao.getGroupIdsOwnFirst(requester),
       dao.getAnyPrivateGroupTalkMembers(pageMeta),
       catsRootLast = categoriesRootLast,
diff --git a/appsv/server/debiki/JsonUtils.scala b/appsv/server/debiki/JsonUtils.scala
index 302e2d8f9..7ebf83ff3 100644
--- a/appsv/server/debiki/JsonUtils.scala
+++ b/appsv/server/debiki/JsonUtils.scala
@@ -72,22 +72,51 @@ object JsonUtils {   MOVE // to talkyard.server.parser.JsonParSer
       case ex: BadJsonException =>
         Bad(ex.getMessage)
     }
   }
 
+  def parseJson(jsonSt: St): JsValue = {
+    // Play uses JacksonJson.parseJsValue, see: play.api.libs.json.
+    // which throws: IOException, JsonParseException, JsonMappingException,
+    // and the latter are subclasses of IOException.
+    try Json.parse(jsonSt)
+    catch {
+      case ex: java.io.IOException =>
+        throwBadJson("TyEPARSJSN", s"Cannot parse text as json: ${ex.getMessage}")
+    }
+  }
+
   def asJsObject(json: JsValue, what: St): JsObject =
     json match {
       case o: JsObject => o
-      case _ => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject")
+      case x => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject, it is a: ${classNameOf(x)}")
     }
 
   def asJsArray(json: JsValue, what: St): Seq[JsValue] =
     json match {
       case a: JsArray => a.value
-      case _ => throwBadJson("TyE0JSARR", s"$what is not a JsArray")
+      case x => throwBadJson("TyE0JSARR", s"$what is not a JsArray, it is a: ${classNameOf(x)}")
     }
 
+  def asInt64(json: JsValue, what: St): i64 =
+    json match {
+      case n: JsNumber =>
+        try n.value.toLongExact
+        catch {
+          case _: java.lang.ArithmeticException =>
+            throwBadJson("TyE0JSINT64", s"$what does not fit in a 64 bit integer")
+        }
+      case x =>
+        throwBadJson("TyE0JSNUM", s"$what is not a JsNumber, it is a: ${classNameOf(x)}")
+    }
+
+  def asInt32(json: JsValue, what: St, min: Opt[i32] = None,
+          max: Opt[i32] = None): i32 = {
+    val int64 = asInt64(json, what)
+    int64To32ThrowIfOutOfRange(int64, what, min = min, max = max)
+  }
+
   def parseJsObject(json: JsValue, fieldName: St): JsObject =
     readJsObject(json, fieldName)
 
   def readJsObject(json: JsValue, fieldName: St): JsObject =
     readOptJsObject(json, fieldName).getOrElse(throwMissing("EsE1FY90", fieldName))
@@ -120,13 +149,13 @@ object JsonUtils {   MOVE // to talkyard.server.parser.JsonParSer
         throwBadJson(
           "EsE4GLK3", s"'$fieldName' is not a JsArray, but a ${classNameOf(bad)}")
     }
   }
 
-  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[JsArray] =
+  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[IndexedSeq[JsValue]] =
     (jv \ fieldName).toOption map {
-      case a: JsArray => a
+      case a: JsArray => a.value
       case JsNull => return None
       case bad =>
         throwBadJson(
             "TyE4MGJ28RP", s"'$fieldName' is not an array, but a ${classNameOf(bad)}")
     }
@@ -353,23 +382,32 @@ object JsonUtils {   MOVE // to talkyard.server.parser.JsonParSer
      readOptInt(json, fieldName = field, altName = altField)
 
 
   def readOptInt(json: JsValue, fieldName: String, altName: String = "",
           min: Opt[i32] = None, max: Opt[i32] = None): Option[Int] = {
-    readOptLong(json, fieldName).orElse(readOptLong(json, altName)) map { valueAsLong =>
-      val maxVal = max getOrElse Int.MaxValue
-      val minVal = min getOrElse Int.MinValue
-      if (valueAsLong > maxVal)
-        throwBadJson("TyEJSNGTMX", s"$fieldName too large: $valueAsLong, max is: $maxVal")
-      if (valueAsLong < minVal)
-        throwBadJson("TyEJSNLTMN", s"$fieldName too small: $valueAsLong, min is: $minVal")
-      valueAsLong.toInt
+    val firstFieldValue = readOptLong(json, fieldName)
+    firstFieldValue.orElse(readOptLong(json, altName)) map { valueAsLong =>
+      val usedName = if (firstFieldValue.isDefined) fieldName else altName
+      int64To32ThrowIfOutOfRange(valueAsLong, usedName, min = min, max = max)
     }
   }
 
+  private def int64To32ThrowIfOutOfRange(valueAsLong: i64, name: St, min: Opt[i32] = None,
+        max: Opt[i32] = None): i32 = {
+    val maxVal = max getOrElse Int.MaxValue
+    val minVal = min getOrElse Int.MinValue
+    if (valueAsLong > maxVal)
+      throwBadJson("TyEJSNGTMX", s"$name too large: $valueAsLong, max is: $maxVal")
+    if (valueAsLong < minVal)
+      throwBadJson("TyEJSNLTMN", s"$name too small: $valueAsLong, min is: $minVal")
+    valueAsLong.toInt
+  }
 
   def readLong(json: JsValue, fieldName: String): Long =
+    parseInt64(json, fieldName)
+
+  def parseInt64(json: JsValue, fieldName: St): i64 =
     readOptLong(json, fieldName) getOrElse throwMissing("EsE6Y8FW2", fieldName)
 
   def parseOptLong(json: JsValue, fieldName: St): Opt[i64] =
     readOptLong(json, fieldName)
 
diff --git a/appsv/server/debiki/ReactJson.scala b/appsv/server/debiki/ReactJson.scala
index 3786ecdac..869bdb0d6 100644
--- a/appsv/server/debiki/ReactJson.scala
+++ b/appsv/server/debiki/ReactJson.scala
@@ -22,10 +22,11 @@ import com.debiki.core.Prelude._
 import controllers.ForumController
 import debiki.dao._
 import ed.server.auth.ForumAuthzContext
 import ed.server.auth.Authz
 import ed.server.http._
+import ed.server.security.{SidStatus, SidOk}
 import java.{lang => jl, util => ju}
 import org.jsoup.Jsoup
 import org.jsoup.nodes.{Element => jsoup_Element}
 import org.jsoup.nodes.{Attribute => jsoup_Attribute}
 import play.api.libs.json._
@@ -791,11 +792,11 @@ class JsonMaker(dao: SiteDao) {
           pageRequest.authzContext.groupIdsEveryoneLast map dao.getTheGroup
 
     val site = if (requester.isStaffOrCoreMember) dao.getSite else None
 
     dao.readOnlyTransaction { tx =>
-      requestersJsonImpl(requester, pageRequest.pageId, watchbarWithTitles,
+      requestersJsonImpl(pageRequest.sid, requester, pageRequest.pageId, watchbarWithTitles,
             restrTopicsCatsLinks, permissions, permsOnSiteTooMany,
             unapprovedPostAuthorIds, myGroupsEveryoneLast, site, tx)
     }
   }
 
@@ -814,19 +815,20 @@ class JsonMaker(dao: SiteDao) {
       request.authzContext.groupIdsEveryoneLast map dao.getTheGroup
 
     val site = if (requester.isStaffOrCoreMember) dao.getSite else None
 
     dao.readOnlyTransaction { tx =>
-      requestersJsonImpl(requester, anyPageId = None, watchbarWithTitles,
+      requestersJsonImpl(request.sid, requester, anyPageId = None, watchbarWithTitles,
             RestrTopicsCatsLinks(JsArray(), Nil, Nil, Nil, Set.empty),
             permissions, permsOnSiteTooMany,
             unapprovedPostAuthorIds = Set.empty, myGroupsEveryoneLast, site, tx)
     }
   }
 
 
-  private def requestersJsonImpl(requester: Participant, anyPageId: Option[PageId],
+  private def requestersJsonImpl(
+        sid: SidStatus, requester: Participant, anyPageId: Option[PageId],
         watchbar: WatchbarWithTitles, restrTopicsCatsLinks: RestrTopicsCatsLinks,
         permissions: Seq[PermsOnPages], permsOnSiteTooMany: PermsOnSite,
         unapprovedPostAuthorIds: Set[UserId],
         myGroupsEveryoneLast: Seq[Group], site: Opt[Site], tx: SiteTransaction)
         : MeAndStuff = {
@@ -936,13 +938,21 @@ class JsonMaker(dao: SiteDao) {
             "postNrsAutoReadLongAgo" -> JsArray(Nil),      // should remove
             "postNrsAutoReadNow" -> JsArray(Nil),
             "marksByPostId" -> JsObject(Nil)))
     }
 
+    // Parts 2 and other parts are all long enough themselves,  so we can include
+    // part 1 in generated html pages. [sid_part1]
+    val sidPart1: JsValue = sid match {
+      case s: SidOk => JsString(s.part1CompId)
+      case _ => JsNull
+    }
+
     // Somewhat dupl code, (2WB4G7) and [B28JG4].
     var json = Json.obj(
       "dbgSrc" -> "4JKW7A0",
+      "mySidPart1" -> sidPart1,
       "id" -> JsNumber(requester.id),
       "userId" -> JsNumber(requester.id), // try to remove, use 'id' instead
       "username" -> JsStringOrNull(requester.anyUsername),
       "fullName" -> JsStringOrNull(requester.anyName),
       "isLoggedIn" -> JsBoolean(true),
diff --git a/appsv/server/debiki/dao/SiteDao.scala b/appsv/server/debiki/dao/SiteDao.scala
index 4223fcabb..1abc52037 100644
--- a/appsv/server/debiki/dao/SiteDao.scala
+++ b/appsv/server/debiki/dao/SiteDao.scala
@@ -124,10 +124,11 @@ class SiteDao(
   with AssetBundleDao
   with SettingsDao
   with SpecialContentDao
   with ed.server.auth.AuthzSiteDaoMixin
   with talkyard.server.authn.AuthnSiteDaoMixin
+  with talkyard.server.sess.SessionSiteDaoMixin
   with ForumDao
   with CategoriesDao
   with PagesDao
   with PagePathMetaDao
   with PageLinksDao
@@ -313,10 +314,13 @@ class SiteDao(
 
   def readTx[R](fn: SiteTx => R): R =
     dbDao2.readOnlySiteTransaction(siteId, mustBeSerializable = true) { fn(_) }
 
   def readOnlyTransactionTryReuse[R](anyTx: Option[SiteTransaction])(fn: SiteTransaction => R): R =
+    readTxTryReuse(anyTx)(fn)
+
+  def readTxTryReuse[R](anyTx: Opt[SiteTx])(fn: SiteTx => R): R =
     anyTx match {
       case Some(tx) => fn(tx)
       case None => readOnlyTransaction(fn)
     }
 
diff --git a/appsv/server/debiki/dao/SystemDao.scala b/appsv/server/debiki/dao/SystemDao.scala
index 06a59b545..8025faf7a 100644
--- a/appsv/server/debiki/dao/SystemDao.scala
+++ b/appsv/server/debiki/dao/SystemDao.scala
@@ -700,10 +700,16 @@ class SystemDao(
   }
 
 
   // ----- The janitor actor
 
+  def deletePersonalDataFromOldSessions(): U = {
+    writeTxLockAllSites { tx =>
+      tx.deletePersonalDataFromOldSessions()
+    }
+  }
+
   def deletePersonalDataFromOldAuditLogEntries(): U = {
     writeTxLockAllSites { tx =>
       tx.deletePersonalDataFromOldAuditLogEntries()
     }
   }
diff --git a/appsv/server/debiki/dao/UserDao.scala b/appsv/server/debiki/dao/UserDao.scala
index 2a02da0ea..76b1d7a28 100644
--- a/appsv/server/debiki/dao/UserDao.scala
+++ b/appsv/server/debiki/dao/UserDao.scala
@@ -362,26 +362,29 @@ trait UserDao {
     require(numDays >= 1, "DwE4PKF8")
 
     val cappedDays = math.min(numDays, 365 * 110)
     val now = globals.now()
 
-    val user = readWriteTransaction { tx =>
+    writeTx { (tx, staleStuff) =>
       var user = tx.loadTheUserInclDetails(userId)
       if (user.isAdmin)
         throwForbidden("DwE4KEF24", "Cannot suspend admins")
 
       val suspendedTill = new ju.Date(now.millis + cappedDays * MillisPerDay)
       user = user.copy(
         suspendedAt = Some(now.toJavaDate),
         suspendedTill = Some(suspendedTill),
         suspendedById = Some(suspendedById),
         suspendedReason = Some(reason.trim))
+
       tx.updateUserInclDetails(user)
-      user
-    }
+      staleStuff.addPatIds(Set(userId))
 
-    logout(user.noDetails, bumpLastSeen = false)
+      logout(user.noDetails, bumpLastSeen = false, anyTx = Some(tx, staleStuff))
+      terminateSessions(  // [end_sess]
+            forPatId = user.id, all = true, anyTx = Some(tx, staleStuff))
+    }
   }
 
 
   def unsuspendUser(userId: UserId): Unit = {
     readWriteTransaction { tx =>
@@ -756,16 +759,23 @@ trait UserDao {
 
     Good(loginGrant)
   }
 
 
-  def logout(pat: Pat, bumpLastSeen: Bo): U = {
-    readWriteTransaction { tx =>
+  def logout(pat: Pat, bumpLastSeen: Bo, anyTx: Opt[(SiteTx, StaleStuff)] = None): U = {
+    if (bumpLastSeen) writeTxTryReuse(anyTx) { (tx, staleStuff) =>
       addUserStats(UserStats(pat.id, lastSeenAt = tx.now))(tx)
+      staleStuff.addPatIds(Set(pat.id))
     }
+
+    UX; COULD // Maybe a WebSocket channel should remember which session & browser it
+    // got started from? Rather than only what user. So can disconnect the right browser,
+    // instead of all the user's browsers. For example, if impersonating,
+    // then, this'll disconnect all current channels of the real person,
+    // and mark hen as away (maybe incorrectly).
+    // [which_ws_session]
     pubSub.unsubscribeUser(siteId, pat)
-    removeUserFromMemCache(pat.id)
   }
 
 
   def loadSiteOwner(): Option[UserInclDetails] = {
     readOnlyTransaction { tx =>
@@ -2188,11 +2198,11 @@ trait UserDao {
     *
     * Tested here:
     * - EdT5WKBWQ2
     */
   def deleteUser(userId: UserId, byWho: Who): UserInclDetails = {
-    val (user, usersGroupIds) = readWriteTransaction { tx =>
+    val (user, usersGroupIds) = writeTx { (tx, staleStuff) =>
       tx.deferConstraints()
 
       val deleter = tx.loadTheParticipant(byWho.id)
       require(userId == deleter.id || deleter.isAdmin, "TyE7UKBW1")
 
@@ -2282,10 +2292,13 @@ trait UserDao {
       tx.insertAuditLogEntry(auditLogEntry)
 
       tx.removeDeletedMemberFromAllPages(userId)
       tx.removeDeletedMemberFromAllGroups(userId)
 
+      // logout(..) done below.  [end_sess]
+      terminateSessions(forPatId = userId, all = true, anyTx = Some(tx, staleStuff))
+
       // Clear the page cache, by clearing all caches.
       emptyCacheImpl(tx)  ; SHOULD_OPTIMIZE // use staleStuff.addPagesWithVisiblePostsBy() instead
 
       (memberDeleted, groupIds)
     }
@@ -2294,11 +2307,15 @@ trait UserDao {
     memCache.clearThisSite()
     COULD // instead uncache only the pages on which hens name appears, plus this:
     // (this not needed, since cleared the site cache just above. Do anyway.)
     uncacheOnesGroupIds(Seq(userId))
     uncacheGroupsMemberLists(usersGroupIds)
+
+    // Or just call:  logout(user, bumpLastSeen = false)
+    pubSub.unsubscribeUser(siteId, user.briefUser)
     removeUserFromMemCache(userId)
+    // terminateSessions() done above.
 
     user
   }
 
 
diff --git a/appsv/server/ed/server/EdAppLoader.scala b/appsv/server/ed/server/EdAppLoader.scala
index 7591cfe44..96726b9be 100644
--- a/appsv/server/ed/server/EdAppLoader.scala
+++ b/appsv/server/ed/server/EdAppLoader.scala
@@ -104,10 +104,11 @@ class EdAppComponents(appLoaderContext: ApplicationLoader.Context)
     new _root_.controllers.SettingsController(cc, context),
     new _root_.controllers.LegalController(cc, context),
     new _root_.controllers.SpecialContentController(cc, context),
     new _root_.controllers.ModerationController(cc, context),
     new _root_.controllers.UserController(cc, context),
+    new talkyard.server.sess.SessionController(cc, context),
     new _root_.controllers.UnsubscriptionController(cc, context),
     new ed.server.summaryemails.UnsubFromSummariesController(cc, context),
     new _root_.controllers.InviteController(cc, context),
     new _root_.controllers.ForumController(cc, context),
     new _root_.controllers.PageController(cc, context),
diff --git a/appsv/server/ed/server/EdController.scala b/appsv/server/ed/server/EdController.scala
index e1999bf53..8f1d4cb04 100644
--- a/appsv/server/ed/server/EdController.scala
+++ b/appsv/server/ed/server/EdController.scala
@@ -9,10 +9,11 @@ import play.api._
 import play.api.libs.Files.TemporaryFile
 import play.api.libs.json.JsValue
 import play.api.mvc._
 import play.{api => p}
 import scala.concurrent.{ExecutionContext, Future}
+import talkyard.server.authn.MinAuthnStrength
 
 
 
 class EdController(cc: ControllerComponents, val context: EdContext)
   extends AbstractController(cc) {
@@ -28,38 +29,55 @@ class EdController(cc: ControllerComponents, val context: EdContext)
   def AsyncGetActionMaybeSkipCookies(avoidCookies: Boolean)(f: GetRequest => Future[Result])
         : mvc.Action[Unit] =
     PlainApiAction(cc.parsers.empty, NoRateLimits, avoidCookies = avoidCookies).async(f)
 
   def AsyncGetActionAllowAnyone(f: GetRequest => Future[Result]): mvc.Action[Unit] =
-    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true).async(f)
+    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
+          allowAnyone = true).async(f)
 
   def AsyncGetActionIsLogin(f: GetRequest => Future[Result]): mvc.Action[Unit] =
     PlainApiAction(cc.parsers.empty, NoRateLimits, isLogin = true).async(f)
 
   def AsyncGetActionIsLoginRateLimited(f: GetRequest => Future[Result]): mvc.Action[Unit] =
     PlainApiAction(cc.parsers.empty, RateLimits.Login, isLogin = true).async(f)
 
-  def AsyncGetActionRateLimited(rateLimits: RateLimits)(f: GetRequest => Future[Result])
+  def AsyncGetActionRateLimited(rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        )(f: GetRequest => Future[Result])
         : mvc.Action[Unit] =
-    PlainApiAction(cc.parsers.empty, rateLimits).async(f)
+    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength).async(f)
 
+  @deprecated("Use GetActionRateLimited instead, remove this, and rename it to this", "")
   def GetAction(f: GetRequest => Result): Action[Unit] =
     PlainApiAction(cc.parsers.empty, NoRateLimits)(f)
 
+  def GetAction2(  // use GetActionRateLimited instead!
+        rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        )(f: GetRequest => Result): Action[Unit] =
+    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength)(f)
+
+  SECURITY; COULD // let MinAuthnStrength be a param, so it's more explicitly visible
+  // in the endpoint fn declaration, here and everywhere — don't let EmbeddedHalfSidOld
+  // be the default anywhere?
   def GetActionAllowAnyone(f: GetRequest => Result): Action[Unit] =
-    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true)(f)
+    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
+          allowAnyone = true)(f)
 
   def GetActionAllowAnyoneRateLimited(rateLimits: RateLimits, avoidCookies: Boolean = false)
         (f: GetRequest => Result): Action[Unit] =
-    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = true, avoidCookies = avoidCookies)(f)
+    PlainApiAction(cc.parsers.empty, rateLimits, MinAuthnStrength.EmbeddingStorageSid12,
+          allowAnyone = true, avoidCookies = avoidCookies)(f)
 
   def GetActionIsLogin(f: GetRequest => Result): Action[Unit] =
     PlainApiAction(cc.parsers.empty, NoRateLimits, isLogin = true)(f)
 
-  def GetActionRateLimited(rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
+  def GetActionRateLimited(
+        rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
         allowAnyone: Boolean = false)(f: GetRequest => Result): Action[Unit] =
-    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = allowAnyone)(f)
+    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
 
   def StaffGetAction(f: GetRequest => Result): Action[Unit] =
     PlainApiActionStaffOnly(cc.parsers.empty)(f)
 
   def AsyncAdminGetAction(f: GetRequest => Future[Result]): Action[Unit] =
@@ -89,15 +107,17 @@ class EdController(cc: ControllerComponents, val context: EdContext)
         allowAnyone: Boolean = false, avoidCookies: Boolean = false)(
         f: JsonPostRequest => Future[Result]): Action[JsValue] =
     PlainApiAction(cc.parsers.json(maxLength = maxBytes),
       rateLimits, allowAnyone = allowAnyone, avoidCookies = avoidCookies).async(f)
 
-  def PostJsonAction(rateLimits: RateLimits, maxBytes: Int,
+  def PostJsonAction(rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        maxBytes: Int,
         allowAnyone: Boolean = false, isLogin: Boolean = false)(
         f: JsonPostRequest => Result): Action[JsValue] =
     PlainApiAction(cc.parsers.json(maxLength = maxBytes),
-      rateLimits, allowAnyone = allowAnyone, isLogin = isLogin)(f)
+        rateLimits, minAuthnStrength, allowAnyone = allowAnyone, isLogin = isLogin)(f)
 
   def AsyncUserPostJsonAction(rateLimits: RateLimits, maxBytes: i32,
         avoidCookies: Bo = false)(
         f: JsonPostRequest => Future[Result]): Action[JsValue] =
     PlainApiAction(cc.parsers.json(maxLength = maxBytes),
@@ -106,19 +126,23 @@ class EdController(cc: ControllerComponents, val context: EdContext)
   def UserPostJsonAction(rateLimits: RateLimits, maxBytes: i32)(
         f: JsonPostRequest => Result): Action[JsValue] =
     PlainApiAction(cc.parsers.json(maxLength = maxBytes),
       rateLimits, authnUsersOnly = true)(f)
 
-  def PostTextAction(rateLimits: RateLimits, maxBytes: Int, allowAnyone: Boolean = false)(
+  def PostTextAction(rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        maxBytes: Int, allowAnyone: Boolean = false)(
         f: ApiRequest[String] => Result): Action[String] =
     PlainApiAction(cc.parsers.text(maxLength = maxBytes),
-      rateLimits, allowAnyone = allowAnyone)(f)
+      rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
 
   SECURITY // add rate limits for staff too
-  def StaffPostJsonAction(maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
+  def StaffPostJsonAction(
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
     PlainApiActionStaffOnly(
-      cc.parsers.json(maxLength = maxBytes))(f)
+      cc.parsers.json(maxLength = maxBytes), minAuthnStrength)(f)
 
   SECURITY // add rate limits for admins — use AdminPostJsonAction2, then remove this & rm '2' from name.
   def AdminPostJsonAction(maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
     PlainApiActionAdminOnly(
       NoRateLimits, cc.parsers.json(maxLength = maxBytes))(f)
@@ -154,19 +178,13 @@ class EdController(cc: ControllerComponents, val context: EdContext)
 
 
   // ----- Site id lookup
 
 
-  def originOf(request: DebikiRequest[_]) =
+  def originOf(request: DebikiRequest[_]): St =
     globals.originOf(request.underlying)
 
-  def originOf(request: RequestHeader) =
+  def originOf(request: RequestHeader): St =
     globals.originOf(request)
 
 
-  def daoFor(request: Request[_]) = {
-    val site = globals.lookupSiteOrThrow(originOf(request))
-    globals.siteDao(site.id)
-  }
-
-
 }
diff --git a/appsv/server/ed/server/auth/AuthzSiteDaoMixin.scala b/appsv/server/ed/server/auth/AuthzSiteDaoMixin.scala
index 5b8ea79bd..86452762c 100644
--- a/appsv/server/ed/server/auth/AuthzSiteDaoMixin.scala
+++ b/appsv/server/ed/server/auth/AuthzSiteDaoMixin.scala
@@ -213,13 +213,16 @@ trait AuthzSiteDaoMixin {
     }
   }
 
 
   /** Returns true/false, + iff false, a why-forbidden debug reason code.
+    *
+    * Also needs any authn strength? Maybe: WhoInfo = ReqerInfo = Opt[(Pat, Opt[AuthnStrength])]?
+    * Maybe Opt[(Pat, AuthnStrength)] where AuthnStrength can be [InternalJob], instead of None?
     */
-  def maySeePostUseCache(pageId: PageId, postNr: PostNr, user: Option[Participant])
-        : (MaySeeOrWhyNot, String) = {
+  def maySeePostUseCache(pageId: PageId, postNr: PostNr, user: Opt[Pat])
+        : (MaySeeOrWhyNot, St) = {
     maySeePostImpl(pageId, postNr, user, anyPost = None, anyTx = None)
   }
 
 
   def maySeePostUseCache(post: Post, pageMeta: PageMeta, ppt: Option[Participant],
diff --git a/appsv/server/ed/server/http/DebikiRequest.scala b/appsv/server/ed/server/http/DebikiRequest.scala
index 747fbd75c..124893c82 100644
--- a/appsv/server/ed/server/http/DebikiRequest.scala
+++ b/appsv/server/ed/server/http/DebikiRequest.scala
@@ -49,10 +49,14 @@ abstract class AuthnReqHeader extends SomethingToRateLimit {
   def context: EdContext = dao.context
   private def security = dao.context.security
   private def globals = dao.context.globals
 
   def site: SiteBrief
+
+  def anyTySession: Opt[TySession]
+  def tySession: TySession = anyTySession getOrElse throwForbidden("TyE0SESS", "Not logged in")
+
   def sid: SidStatus
   def xsrfToken: XsrfOk
   def browserId: Option[BrowserId]
   def user: Option[Participant] // REFACTOR RENAME to 'requester' (and remove 'def requester' below)
                         // COULD? add a 'Stranger extends User' and use instead of None ?
@@ -77,11 +81,11 @@ abstract class AuthnReqHeader extends SomethingToRateLimit {
   def tracerSpanLogEventValue(eventName: String, value: String): Unit = {
     tracerSpan.log(com.google.common.collect.ImmutableMap.of("event", eventName, "value", value))
   }
 
   def isViaApiSecret: Boolean = sid match { // should be case obj AuthnMethod.ApiSecret instead? [5BKRH02]
-    case SidOk("_api_secret_", 0, _) => true
+    case SidOk(TySession.ApiSecretPart12, 0, _) => true
     case _ => false
   }
 
   require(site.id == dao.siteId, "EsE76YW2")
   require(user.forall(_.id == sid.userId.getOrDie("TyE2KWQP4")), "TyE7PUUY2")
@@ -163,11 +167,10 @@ abstract class AuthnReqHeader extends SomethingToRateLimit {
   def theRoleId: UserId = anyRoleId getOrElse throwForbidden("DwE86Wb7", "Not authenticated")
 
   def isGuest: Boolean = user.exists(_.isGuest)
   def isStaff: Boolean = user.exists(_.isStaff)
 
-  def session: mvc.Session = request.session
 
   def ip: IpAddress = security.realOrFakeIpOf(request)
 
   /**
    * Approximately when the server started serving this request.
diff --git a/appsv/server/ed/server/http/PageRequest.scala b/appsv/server/ed/server/http/PageRequest.scala
index 005b38f1f..99d9b4d92 100644
--- a/appsv/server/ed/server/http/PageRequest.scala
+++ b/appsv/server/ed/server/http/PageRequest.scala
@@ -35,10 +35,11 @@ import play.api.mvc.Request
   * whilst functions that instead require an Option are named simply "page" or
   * "pageParts".
   */
 class PageRequest[A](
   val site: SiteBrief,
+  val anyTySession: Opt[TySession],
   val sid: SidStatus,
   val xsrfToken: XsrfOk,
   val browserId: Option[BrowserId],
   val user: Option[Participant],
   val pageExists: Boolean,
@@ -138,19 +139,20 @@ class PageRequest[A](
   * from the database). EmbeddedTopicsController constructs an empty dummy page
   * when showing comments for an URL for which no page has yet been created.
   */
 class DummyPageRequest[A](
   siteIdAndCanonicalHostname: SiteBrief,
+  anyTySession: Opt[TySession],
   sid: SidStatus,
   xsrfToken: XsrfOk,
   browserId: Option[BrowserId],
   user: Option[Participant],
   pageExists: Boolean,
   pagePath: PagePath,
   pageMeta: PageMeta,
   dao: SiteDao,
   request: Request[A]) extends PageRequest[A](
-    siteIdAndCanonicalHostname, sid, xsrfToken, browserId, user, pageExists,
+    siteIdAndCanonicalHostname, anyTySession, sid, xsrfToken, browserId, user, pageExists,
     pagePath, Some(pageMeta), altPageId = None, embeddingUrl = None,
     dao = dao, request = request) {
 
 }
diff --git a/appsv/server/ed/server/http/PlainApiActions.scala b/appsv/server/ed/server/http/PlainApiActions.scala
index 072b1d9c7..0ffb5eba2 100644
--- a/appsv/server/ed/server/http/PlainApiActions.scala
+++ b/appsv/server/ed/server/http/PlainApiActions.scala
@@ -32,45 +32,54 @@ import scala.util.{Failure, Success}
 import EdSecurity.AvoidCookiesHeaderName
 import scala.collection.mutable
 import play.api.http.{HeaderNames => p_HNs}
 import play.api.mvc.{Results => p_Results}
 import talkyard.server.TyLogging
+import talkyard.server.authn.MinAuthnStrength
 
 
-/** Play Framework Actions for requests to Debiki's HTTP API.
+/** Play Framework Actions for requests to Talkyard's HTTP API.
   */
 class PlainApiActions(
   val safeActions: SafeActions,
   val globals: Globals,
   val security: EdSecurity,
   val rateLimiter: RateLimiter) extends TyLogging {
 
   import EdHttp._
   import security.DiscardingSecureCookie
-  import security.DiscardingSessionCookie
+  import security.DiscardingSessionCookies
   import safeActions.ExceptionAction
 
-  def PlainApiAction[B](parser: BodyParser[B],
-        rateLimits: RateLimits, allowAnyone: Bo = false, isLogin: Bo = false,
+  def PlainApiAction[B](
+        parser: BodyParser[B],
+        rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        allowAnyone: Bo = false,
+        isLogin: Bo = false,
         authnUsersOnly: Bo = false,
-        avoidCookies: Bo = false, skipXsrfCheck: Bo = false)
-        : ActionBuilder[ApiRequest, B] =
-    PlainApiActionImpl(parser, rateLimits,
+        avoidCookies: Bo = false,
+        skipXsrfCheck: Bo = false,
+        ): ActionBuilder[ApiRequest, B] =
+    PlainApiActionImpl(parser, rateLimits, minAuthnStrength = minAuthnStrength,
         authnUsersOnly = authnUsersOnly,
         allowAnyone = allowAnyone, isLogin = isLogin, avoidCookies = avoidCookies,
         skipXsrfCheck = skipXsrfCheck)
 
-  def PlainApiActionStaffOnly[B](parser: BodyParser[B]): ActionBuilder[ApiRequest, B] =
-    PlainApiActionImpl(parser, NoRateLimits, staffOnly = true)
+  def PlainApiActionStaffOnly[B](
+          parser: BodyParser[B],
+          minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+          ): ActionBuilder[ApiRequest, B] =
+    PlainApiActionImpl(parser, NoRateLimits, minAuthnStrength, staffOnly = true)
 
   def PlainApiActionAdminOnly[B](rateLimits: RateLimits, parser: BodyParser[B])
         : ActionBuilder[ApiRequest, B] =
     PlainApiActionImpl(parser, rateLimits, adminOnly = true)
 
   def PlainApiActionApiSecretOnly[B](rateLimits: RateLimits, parser: BodyParser[B])
         : ActionBuilder[ApiRequest, B] =
-    PlainApiActionImpl(parser, rateLimits, viaApiSecretOnly = true)
+    PlainApiActionImpl(parser, rateLimits, MinAuthnStrength.ApiSecret, viaApiSecretOnly = true)
 
   def PlainApiActionSuperAdminOnly[B](parser: BodyParser[B])
         : ActionBuilder[ApiRequest, B] =
     PlainApiActionImpl(parser, NoRateLimits, superAdminOnly = true)
 
@@ -85,10 +94,11 @@ class PlainApiActions(
     * doesn't map to any login entry.
     * The SidStatusRequest.sidStatus passed to the action is either SidAbsent or a SidOk.
     */
   def PlainApiActionImpl[B](aParser: BodyParser[B],
         rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
         adminOnly: Boolean = false,
         staffOnly: Boolean = false,
         authnUsersOnly: Bo = false,
         allowAnyone: Boolean = false,  // try to delete 'allowAnyone'? REFACTOR
         avoidCookies: Boolean = false,
@@ -275,11 +285,12 @@ class PlainApiActions(
               "TyE60MREH35", "Wrong password")
         throwForbiddenIf(request.path != "/-/handle-email", "TyE406MSE35", "Wrong path")
 
         val sysbot = dao.getTheUser(SysbotUserId)
         return runBlockIfAuthOk(request, site, dao, Some(sysbot),
-              SidOk("_api_secret_", 0, Some(SysbotUserId)),
+              Some(TySession.singleApiCallSession(asPatId = SysbotUserId)),
+              SidOk(TySession.ApiSecretPart12, 0, Some(SysbotUserId)),
               XsrfOk("_email_webhook_"), None, block)
       }
 
       DO_AFTER // 2021-08-01 enable this always. Test in dev-test first, for now.
       throwForbiddenIf(Globals.isDevOrTest && !dao.getWholeSiteSettings().enableApi,
@@ -325,26 +336,29 @@ class PlainApiActions(
 
       // See: [non_adm_api_usr] for code that does slightly different things
       // if isn't sysbot.
 
       runBlockIfAuthOk(request, site, dao, Some(user),
+          Some(TySession.singleApiCallSession(asPatId = user.id)),
           // SECURITY minor: Less error prone with a Bool field instead of this magic string.
-          SidOk("_api_secret_", 0, Some(user.id)), XsrfOk("_api_secret_"), None, block)
+          // ... getting fixed, see TySession just above, [btr_sid].
+          SidOk(TySession.ApiSecretPart12, 0, Some(user.id)),
+          XsrfOk(TySession.ApiSecretPart12), None, block)
     }
 
 
     private def invokeBlockAuthViaCookie[A](request: Request[A], corsInfo: CorsInfo,
           site: SiteBrief,
           dao: SiteDao, siteSettings: AllSettings,
           block: ApiRequest[A] => Future[Result]): Future[Result] = {
 
       // Why avoid cookies? In an embedded comments iframe, cookies frequently get blocked
-      // by Privacy Badger or iOS or browser settings for 3rd-party-cookies.
+      // by iOS ITP or FF ETP or browser settings for 3rd-party-cookies.
       // The embedded comments show-page etc endpoints sets avoidCookies,
       // so we know, here, that we should avoid setting any cookies.  [NOCOOKIES]
       // And, for subsequent requests — to *other* endpoints — the browser Javascript code sets
-      // the AvoidCookiesHeaderName header, so we won't froget that we should avoid cookies here.
+      // the AvoidCookiesHeaderName header, so we won't forget that we should avoid cookies here.
       val hasCookiesAlready = request.cookies.exists(_.name != "esCoE2eTestPassword")
       val maySetCookies = hasCookiesAlready || {   // TODO
         val shallAvoid = avoidCookies || {
           val avoidCookiesHeaderValue = request.headers.get(AvoidCookiesHeaderName)
           if (avoidCookiesHeaderValue.isEmpty) false
@@ -356,11 +370,13 @@ class PlainApiActions(
 
       // Below: A bit dupl code, same as for WebSocket upgrade requests. [WSHTTPREQ]
 
       val expireIdleAfterMins = siteSettings.expireIdleAfterMins
 
-      val (actualSidStatus, xsrfOk, newCookies) = corsInfo match {
+      val CheckSidAndXsrfResult(
+              anyTySession, actualSidStatus, xsrfOk, newCookies, delFancySidCookies) =
+            corsInfo match {
         case ci: CorsInfo.OkayCrossOrigin =>
           // Cross-origin requests with credentials (i.e. session id cookie)
           // not yet tested and thought thrown.
           // Continue as a stranger (a not logged in user).
           throwForbiddenIf(ci.hasCorsCreds,  // [CORSCREDSUNIMPL]
@@ -368,21 +384,23 @@ class PlainApiActions(
                   (i.e. session id cookie or header) not implemented""")
           // Skip any xsrf token check since this origin is *supposed* to do
           // cross-origin requests.
           // Currently only publicly accessible data can be seen, since proceeding
           // with no session id.
-          (SidAbsent, XsrfOk("cors_no_xsrf"), Nil)
+          CheckSidAndXsrfResult(
+                None, SidAbsent, XsrfOk("cors_no_xsrf"), Nil, Nil)
 
-        case _: CorsInfo.SameOrigins | CorsInfo.NotCorsNoOriginHeader =>
+        case ci =>
+          dieIf(ci.isCrossOrigin, "TyEJ2503TKHJ")
           security.checkSidAndXsrfToken(
-                request, anyRequestBody = Some(request.body), siteId = site.id,
+                request, anyRequestBody = Some(request.body), site, dao,
                 expireIdleAfterMins, maySetCookies = maySetCookies,
                 skipXsrfCheck = skipXsrfCheck)
       }
 
       // Ignore and delete any broken or expired session id cookie.
-      val (mendedSidStatus, deleteSidCookie) =
+      val (mendedSidStatus, deleteAllSidCookies) =
         if (actualSidStatus.canUse) (actualSidStatus, false)
         else (SidAbsent, true)
 
       val (anyBrowserId, newBrowserIdCookie) =  // [5JKWQ21]
         if (maySetCookies) {
@@ -409,68 +427,85 @@ class PlainApiActions(
                 dao.getUserBySessionId(mendedSidStatus) getOrIfBad { ex =>
                   throw ex
                 }
 
           runBlockIfAuthOk(request, site, dao, anyUserMaybeSuspended,
-                mendedSidStatus, xsrfOk, anyBrowserId, block)
+                anyTySession, mendedSidStatus, xsrfOk, anyBrowserId, block)
         }
         catch {
+          // CLEAN_UP have getUserBySessionId() return an error instead of throwing
           case _: LoginNotFoundException =>
             // This might happen if I manually deleted stuff from the
             // database during development, or if the server has fallbacked
             // to a standby database.
+            // (Does it make sense to delete any related session from sessions_t?)
             throw ResultException(InternalErrorResult2(i"""
               |Internal error, please try again. For example, reload the page. [DwE034ZQ3]
               |
               |Details: A certain login id has become invalid. I just gave you a new id,
               |but you will probably need to login again.""")
-              .discardingCookies(DiscardingSessionCookie))
+              .discardingCookies(DiscardingSessionCookies: _*))
         }
 
       val resultOkSid =
-        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteSidCookie) {
+        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteAllSidCookies
+              && delFancySidCookies.isEmpty) {
           resultOldCookies
         }
         else {
           resultOldCookies.map({ result =>
             var resultWithCookies = result
-              .withCookies(newCookies ::: newBrowserIdCookie: _*)
-              .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
-            if (deleteSidCookie) {
+                  .withCookies(newCookies ::: newBrowserIdCookie: _*)
+                  .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
+            if (deleteAllSidCookies) {
+              resultWithCookies =
+                    resultWithCookies.discardingCookies(DiscardingSessionCookies: _*)
+            }
+            else if (delFancySidCookies.nonEmpty) {
               resultWithCookies =
-                resultWithCookies.discardingCookies(DiscardingSessionCookie)
+                    resultWithCookies.discardingCookies(delFancySidCookies: _*)
             }
             resultWithCookies
           })(executionContext)
         }
 
       resultOkSid
     }
 
 
     private def runBlockIfAuthOk[A](request: Request[A], site: SiteBrief, dao: SiteDao,
-          anyUserMaybeSuspended: Option[Participant], sidStatus: SidStatus,
+          anyUserMaybeSuspended: Option[Participant],
+          anyTySession: Opt[TySession], sidStatus: SidStatus,
           xsrfOk: XsrfOk, browserId: Option[BrowserId], block: ApiRequest[A] => Future[Result])
           : Future[Result] = {
 
       // Maybe the user was logged in in two different browsers, and deleted hens account
-      // in one browser and got logged out there, only.
-      if (anyUserMaybeSuspended.exists(_.isDeleted))
+      // in one browser and got logged out, when this request was going on already?
+      if (anyUserMaybeSuspended.exists(_.isDeleted)) {
+        // A race. Any session already deleted by UserDao [end_sess],
+        dieIf(dao.listPatsSessions(anyUserMaybeSuspended.get.id).nonEmpty, "TyESTILLSID01")
         return Future.successful(
-          ForbiddenResult("TyEUSRDLD", "That account has been deleted")
-            .discardingCookies(DiscardingSessionCookie))
+              ForbiddenResult("TyEUSRDLD", "That account has been deleted")
+                  .discardingCookies(DiscardingSessionCookies: _*))
+      }
 
       val isSuspended = anyUserMaybeSuspended.exists(_.isSuspendedAt(new ju.Date))
 
-      if (isSuspended && request.method != "GET")
+      if (isSuspended && request.method != "GET") {
+        // A race. Any session already deleted by UserDao [end_sess],
+        dieIf(dao.listPatsSessions(anyUserMaybeSuspended.get.id).nonEmpty, "TyESTILLSID02")
         return Future.successful(
-            ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
-              .discardingCookies(DiscardingSessionCookie))
+              ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
+                  .discardingCookies(DiscardingSessionCookies: _*))
+      }
 
       val anyUser =
-        if (isSuspended) None
-        else anyUserMaybeSuspended
+            if (!isSuspended) anyUserMaybeSuspended
+            else {
+              // If suspended, one can still see publicly visible things.
+              None
+            }
 
       // Re the !superAdminOnly test: Do allow access for superadmin endpoints,
       // so they can reactivate this site, in case this site is the superadmin site itself.
       // Sync w WebSocket endpoint. [SITESTATUS].
       if (!superAdminOnly) site.status match {
@@ -492,10 +527,47 @@ class PlainApiActions(
         throwLoginAsStaff(request)
 
       if (adminOnly && !anyUser.exists(_.isAdmin) && !isLogin)
         throwLoginAsAdmin(request)
 
+      // Some staffOnly endpoints are okay with an embedded session — namely
+      // if moderating embedded comments [mod_emb_coms_sid]. But admin endpoints always
+      // need the full session id (not just parts 1+2+3, but also part 4 HttpOnly).
+      dieIf((adminOnly || superAdminOnly) && !minAuthnStrength.fullSidRequired, "TyE502MGE6M1")
+
+      if (anyTySession.exists(_.isApiCall)) {
+        // Fine, we've checked an API secret, no session parts needed.
+        assert(anyTySession.get.part1CompId == TySession.ApiSecretPart1, "TyE502MEG5")
+      }
+      else if (anyTySession.forall(_.part4Present)) {
+        // Then either 1) the user isn't logged in; there is no session — that's fine;
+        // we'll do authZ checks later; some endpoints (e.g. for logging in) are accessible
+        // also if not logged in.
+        // Or 2) the user is logged in, and this request does include the HttpOnly
+        // session part — all fine.
+      }
+      else if (minAuthnStrength.fullSidRequired || staffOnly || adminOnly || superAdminOnly) {
+        // Part 4 HttpOnly is required, but is missing.  Dupl code [btr_sid_part_4]
+        assert(anyTySession.isDefined, "TyE04MWG245")
+        assert(anyTySession.get.part4Absent, "TyE04MWG246")
+        val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
+        val onlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
+        val doUseFancySid = onlyFancySid || tryFancySid
+        if (doUseFancySid) {
+          throwForbidden("TyEWEAKSID_",
+                s"Please log out and log in, to get a complete session id — \n" +
+                s"this endpoint, ${request.path}, requires the HttpOnly part of the session id")
+        }
+      }
+      else {
+        // Part 4 of the session is missing, and the current endpoint doesn't need part 4
+        // (such endpoints are for embedded discussions — then, cookies don't work (they
+        // generally don't work in iframes), so we're getting only session parts 1+2(+3) via
+        // javascript and custom HTTP headers).
+        dieUnless(anyTySession.exists(_.part4Absent), "TyE70MWEG25SM")
+      }
+
       if (superAdminOnly) {
         globals.config.superAdmin.siteIdString match {
           case Some(siteId) if site.id.toString == siteId =>
             // Fine: this (i.e. 'site') is the superadmin site, so we're allowed to access
             // the superadmin endpoints.
@@ -588,11 +660,11 @@ class PlainApiActions(
           // Or maybe delete guest session, show a message that one is now logged out?
         }
       }
 
       val apiRequest = ApiRequest[A](
-        site, sidStatus, xsrfOk, browserId, anyUser, dao, request)
+        site, anyTySession, sidStatus, xsrfOk, browserId, anyUser, dao, request)
 
       rateLimiter.rateLimit(rateLimits, apiRequest)
 
       // COULD use markers instead for site id and ip, and perhaps uri too? Dupl code [5KWC28]
       val requestUriAndIp = s"site $site, ip ${apiRequest.ip}: ${apiRequest.uri}"
@@ -644,13 +716,15 @@ class PlainApiActions(
           logger.debug(
             s"API request exception: ${classNameOf(exception)} [DwE4P7], $requestUriAndIp")
       })(executionContext)
 
       if (isSuspended) {
-        // BUG: (old? can still happen?) We won't get here if e.g. a 403 Forbidden exception
-        // was thrown because 'anyUser' was set to None. How solve that?
-        result = result.map(_.discardingCookies(DiscardingSessionCookie))(executionContext)
+        // Harmless "problem": This code won't run, if we started handling the request,
+        // but then an exception got thrown (e.g. a 404 Access Denied ResultException,
+        // see above).  Then, the cookies won't get deleted — which doesn't really matter,
+        // since they're unusable; session deleted server side already.  [btr_sid]
+        result = result.map(_.discardingCookies(DiscardingSessionCookies: _*))(executionContext)
       }
       result
     }
   }
 }
diff --git a/appsv/server/ed/server/http/package.scala b/appsv/server/ed/server/http/package.scala
index 87e8d1b28..fe71cde9f 100644
--- a/appsv/server/ed/server/http/package.scala
+++ b/appsv/server/ed/server/http/package.scala
@@ -30,25 +30,27 @@ package object http {
     _root_.controllers.Utils.OkApiJson(json)
 
 
   case class AuthnReqHeaderImpl(
     site: SiteBrief,
+    anyTySession: Opt[TySession],
     sid: SidStatus,
     xsrfToken: XsrfOk,
-    browserId: Option[BrowserId],
-    user: Option[Participant],
+    browserId: Opt[BrowserId],
+    user: Opt[Pat],
     dao: SiteDao,
     request: RequestHeader) extends AuthnReqHeader {
   }
 
 
   case class ApiRequest[A](   // RENAME to AuthnReqImpl
     site: SiteBrief,
+    anyTySession: Opt[TySession],
     sid: SidStatus,
     xsrfToken: XsrfOk,
-    browserId: Option[BrowserId],
-    user: Option[Participant],
+    browserId: Opt[BrowserId],
+    user: Opt[Pat],
     dao: SiteDao,
     request: Request[A]) extends DebikiRequest[A] {
   }
 
   /** A request with no post data. */
diff --git a/appsv/server/ed/server/jobs/Janitor.scala b/appsv/server/ed/server/jobs/Janitor.scala
index 23350c96f..753166d7e 100644
--- a/appsv/server/ed/server/jobs/Janitor.scala
+++ b/appsv/server/ed/server/jobs/Janitor.scala
@@ -110,10 +110,11 @@ class JanitorActor(val globals: Globals) extends Actor {
   }
 
 
   private def findAndDeleteOldStuff(): Unit = {
     val dao = globals.systemDao
+    dao.deletePersonalDataFromOldSessions()
     dao.deletePersonalDataFromOldAuditLogEntries()
     dao.deletePersonalDataFromOldSpamCheckTasks()
     dao.deleteOldUnusedUploads()
   }
 
diff --git a/appsv/server/ed/server/pubsub/SubscriberController.scala b/appsv/server/ed/server/pubsub/SubscriberController.scala
index e938a66bd..0cee9c09b 100644
--- a/appsv/server/ed/server/pubsub/SubscriberController.scala
+++ b/appsv/server/ed/server/pubsub/SubscriberController.scala
@@ -23,15 +23,16 @@ import com.debiki.core.Prelude._
 import debiki.EdHttp._
 import debiki._
 import talkyard.server.JsX
 import ed.server.{EdContext, EdController}
 import ed.server.http._
+import ed.server.security.CheckSidAndXsrfResult
 import javax.inject.Inject
 import org.scalactic.{Bad, Good, Or}
 import play.{api => p}
 import p.libs.json.{JsValue, Json}
-import p.mvc.{Action, ControllerComponents, RequestHeader, Result}
+import p.mvc.{Action, ControllerComponents, RequestHeader => play_RequestHeader, Result}
 import scala.concurrent.Future
 import talkyard.server.TyLogging
 import talkyard.server.RichResult
 import talkyard.server.pubsub.WebSocketMessageHandler
 
@@ -44,18 +45,18 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
 
   import context.globals
 
 
   def webSocket: p.mvc.WebSocket = p.mvc.WebSocket.acceptOrResult[JsValue, JsValue] {
-        request: RequestHeader =>
+        request: play_RequestHeader =>
     webSocketImpl(request)
       // map { if  Left[Result = Problem ... log to admin problem log ?  }
       // [ADMERRLOG]
   }
 
 
-  private def webSocketImpl(request: RequestHeader): Future[Either[
+  private def webSocketImpl(request: play_RequestHeader): Future[Either[
         // Either an error response, if we reject the connection.
         Result,
         // Or an In and Out stream, for talking with the client.
         Flow[JsValue, JsValue, _]]] = {
 
@@ -109,11 +110,11 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
     }
   }
 
 
 
-  private def authenticateWebSocket(site: SiteBrief, request: RequestHeader)
+  private def authenticateWebSocket(site: SiteBrief, request: play_RequestHeader)
         : AuthnReqHeaderImpl Or Result = {
     import tyCtx.security
 
     val dao = globals.siteDao(site.id)
     val requestOrigin = request.headers.get(play.api.http.HeaderNames.ORIGIN)
@@ -136,16 +137,18 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
     val expireIdleAfterMins = dao.getWholeSiteSettings().expireIdleAfterMins
 
     // We check the xsrf token later — since a WebSocket upgrade request
     // cannot have a request body or custom header with xsrf token.
     // (checkSidAndXsrfToken() won't throw for GET requests. [GETNOTHROW])
-    val (sessionId, xsrfOk, newCookies) =
+    val CheckSidAndXsrfResult(anyTySession, sessionId, xsrfOk, newCookies, delFancySidCookies) =
           security.checkSidAndXsrfToken(
-                request, anyRequestBody = None, siteId = site.id,
+                request, anyRequestBody = None, site, dao,
                 expireIdleAfterMins = expireIdleAfterMins, maySetCookies = false,
                 skipXsrfCheck = false)
 
+    COULD // delete any delFancySidCookies.
+
     // Needs to have a cookie already. [WSXSRF]
     dieIf(newCookies.nonEmpty, "TyE503RKDJL2")
     throwForbiddenIf(xsrfOk.value.isEmpty, "TyEWS0XSRFCO", "No xsrf cookie")
 
     SECURITY // Later: Maybe shouldn't show that the sid is bad?
@@ -153,11 +156,26 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
       return Bad(ForbiddenResult("TyEWSSID", "Bad session id"))
 
     if (sessionId.userId.isEmpty)
       return Bad(ForbiddenResult("TyEWS0SID", "No session id"))
 
-    // For now, let's require a browser id cookie — then, *in some cases* simpler
+    // For now, let's require part 4 HttpOnly to use WebSocket. And some time later
+    // maybe reconsider, and e.g. allow live updates of embedded comments.
+    if (anyTySession.exists(_.part4Absent)) {
+      // Dupl code [btr_sid_part_4]
+      val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
+      val onlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
+      val doUseFancySid = onlyFancySid || tryFancySid
+      if (doUseFancySid) {
+        UNTESTED
+        throwForbidden("TyEWEAKSIDWS",
+              s"Please log out and log in, to get a complete session id — \n" +
+              s"WebSockets, ${request.path}, requires the HttpOnly part of the session id")
+      }
+    }
+
+    // For now, let's require a browser id cookie — then, *in some cases* simpler
     // to detect WebSocket abuse or attacks? (Also see: [GETLOGIN] — an id cookie
     // needs to be set also via GET requests, if they're for logging in.)
     val anyBrowserId = security.getAnyBrowserId(request)
     //if (anyBrowserId.isEmpty)
     //  return Bad(ForbiddenResult("TyEWS0BRID", "No browser id"))
@@ -178,21 +196,20 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
               other.accountType} account: ${other.nameHashId}"))
     }
 
     if (requesterMaybeSuspended.isDeleted)
       return Bad(ForbiddenResult("TyEWSUSRDLD", "User account deleted")
-          .discardingCookies(security.DiscardingSessionCookie))
-          // + discard browser id co too   *edit:* Why?
+          .discardingCookies(security.DiscardingSessionCookies: _*))
 
     val isSuspended = requesterMaybeSuspended.isSuspendedAt(new java.util.Date)
     if (isSuspended)
       return Bad(ForbiddenResult("TyEWSSUSPENDED", "Your account has been suspended")
-          .discardingCookies(security.DiscardingSessionCookie))
+          .discardingCookies(security.DiscardingSessionCookies: _*))
 
     val requester = requesterMaybeSuspended
 
-    val authnReq = AuthnReqHeaderImpl(site, sessionId, xsrfOk,
+    val authnReq = AuthnReqHeaderImpl(site, anyTySession, sessionId, xsrfOk,
           anyBrowserId, Some(requester), dao, request)
 
     Good(authnReq)
   }
 
diff --git a/appsv/server/ed/server/security/package.scala b/appsv/server/ed/server/security/package.scala
index 2f07edb3f..54ede97c8 100644
--- a/appsv/server/ed/server/security/package.scala
+++ b/appsv/server/ed/server/security/package.scala
@@ -1,7 +1,7 @@
 /**
- * Copyright (C) 2011-2017 Kaj Magnus Lindberg
+ * Copyright (c) 2011-2017, 2021 Kaj Magnus Lindberg
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as
  * published by the Free Software Foundation, either version 3 of the
  * License, or (at your option) any later version.
@@ -16,21 +16,23 @@
  */
 
 package ed.server.security
 
 import com.debiki.core._
-import com.debiki.core.isDevOrTest
 import com.debiki.core.Prelude._
 import debiki.{EdHttp, EffectiveSettings, Globals}
 import ed.server.http.{DebikiRequest, JsonOrFormDataBody}
 import play.api.mvc.{Cookie, DiscardingCookie, RequestHeader}
 import scala.util.Try
 import EdSecurity._
 import ed.server.auth.MayMaybe
 import play.api.http.{HeaderNames => p_HNs}
+import play.api.libs.json.{JsObject, JsString, JsValue}
 import talkyard.server.TyLogger
-
+import talkyard.server.sess.SessionSiteDaoMixin
+import talkyard.server.http
+import ed.server.http.AuthnReqHeader
 
 
 sealed abstract class XsrfStatus { def isOk = false }
 case object XsrfExpired extends XsrfStatus
 case object XsrfBad extends XsrfStatus
@@ -42,10 +44,32 @@ case class XsrfOk(value: String) extends XsrfStatus {
     value.takeWhile(_ != '.').toLong
   }
 }
 
 
+private case class CheckSidResult(
+  anyTySession: Opt[TySession],
+  sidStatus: SidStatus,
+  createCookies: List[Cookie] = Nil,
+  discardCookies: List[DiscardingCookie] = Nil)
+
+
+private object CheckSidResult {
+  def noSession(
+        sidStatus: SidStatus,
+        // discardAllSessionCookies: Bo = false,
+        discardCookies: List[DiscardingCookie] = Nil): CheckSidResult = {
+    dieIf(sidStatus.canUse && sidStatus != SidAbsent, "TyE2MFKJ063I")
+    //dieIf(discardAllSessionCookies && discardCookies.nonEmpty, "TyE2MFKJ0632")
+    CheckSidResult(
+          anyTySession = None, sidStatus, discardCookies = discardCookies)
+          /* Cannot access here:
+              if (discardAllSessionCookies) EdSecurity.DiscardingSessionCookies.toList
+              else discardCookies) */
+  }
+}
+
 
 // RENAME to AuthnMethod,
 // and AuthnMethod.ApiSecret [5BKRH02], SidOk –> SessionId, None, BadSessionIdFormat, BadSessionIdHash?
 sealed abstract class SidStatus {
   def canUse = false
@@ -57,19 +81,36 @@ case object SidAbsent extends SidStatus {
   override def canUse = true
 }
 
 case object SidBadFormat extends SidStatus
 case object SidBadHash extends SidStatus
-case class SidExpired(minutesOld: Long, maxAgeMins: Long) extends SidStatus
+case class SidExpired(
+  minutesOld: i64,
+  maxAgeMins: i64,
+  wasForPatId: Opt[PatId]) extends SidStatus
+
+case class SidDeleted(
+  value: St,
+  wasForPatId: Opt[PatId]) extends SidStatus {
+}
 
 
 
+// CLEAN_UP REFACTOR REMOVE this class? Use only TySession instead.
 case class SidOk(
+  // If fancy sid, is part 1 + 2 = 16 + 24 = 40 chars.
   value: String,
   ageInMillis: Long,
   override val userId: Option[UserId]) extends SidStatus {
 
+  // We should never include session id part 3 — so check that the length is just part 1 + 2
+  // or that there's a '.' — then it's an old silly sid.
+  dieIf(value.length != TySession.SidLengthCharsPart12 && !value.contains('.'),
+        "TyEBADSID12LEN", s"Bad session id parts 1 + 2: '$value'")
+
+  def part1CompId: St = value take TySession.SidLengthCharsPart1
+
   override def canUse = true
 }
 
 
 
@@ -79,10 +120,17 @@ case class SidOk(
   *              it's possible that the browser has disabled cookies?
   */
 case class BrowserId(cookieValue: String, isNew: Boolean)
 
 
+case class CheckSidAndXsrfResult(
+  anyTySession: Opt[TySession],
+  sidStatus: SidStatus,
+  xsrfStatus: XsrfOk,
+  cookiesToAdd: List[Cookie],
+  cookiesToDelete: List[DiscardingCookie])
+
 
 object EdSecurity {
 
   /** Tracker blockers and iOS and security settings, might block cookies [NOCOOKIES],
     * might break embedded comments. So we sometimes send back headers instead, and remember
@@ -112,10 +160,15 @@ object EdSecurity {
     * restarts and I want to be able to restart the app servers at
     * any time so I don't use Lift's stateful session stuff so very much.
     */
   val SessionIdCookieName = "dwCoSid"
 
+  // See TySession [cookie_theory].
+  val SessionIdPart123CookieName = "TyCoSid123"
+  val SessionIdPart4HttpOnlyCookieName = "TyCoSid4"
+  val SessionIdPart5StrictCookieName = "TyCoSid5"
+
   /** Don't rename. Is used by AngularJS: AngularJS copies the value of
     * this cookie to the HTTP header just above.
     * See: http://docs.angularjs.org/api/ng.$http, search for "XSRF-TOKEN".
     * CLEAN_UP do rename to tyCoXsrf, Angular is since long gone.
     */
@@ -339,40 +392,77 @@ class EdSecurity(globals: Globals) {
    *
    * WebSocket: xsrf token checked in 1st message from the client instead [WSXSRF]
    * but not here (the WebSocket upgrade request has no body, no custom headers).
    */
   def checkSidAndXsrfToken[A](request: RequestHeader, anyRequestBody: Option[A],
-        siteId: SiteId, expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
-        : (SidStatus, XsrfOk, List[Cookie]) = {
+        site: SiteBrief, dao: SessionSiteDaoMixin,
+        expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
+        : CheckSidAndXsrfResult = {
+
+
+    // ----- Check session id
 
     val expireIdleAfterMillis: Long = expireIdleAfterMins * MillisPerMinute
 
     // If we cannot use cookies, then the sid is sent in a header. [NOCOOKIES]
-    val anySessionIdCookieValue: Opt[St] = urlDecodeCookie(SessionIdCookieName, request)
-    val anySessionId: Opt[St] =
-          anySessionIdCookieValue orElse request.headers.get(SessionIdHeaderName)
+    val anySillySidInCookie: Opt[St] = urlDecodeCookie(SessionIdCookieName, request)
+    val anySidHeaderValue: Opt[St] = request.headers.get(SessionIdHeaderName)
+    val (anySillySidInHeader, anyFancySidPart12Maybe3InHeader) = anySidHeaderValue match {
+      case None => (None, None)
+      case Some(value) =>
+        if (value.contains('.')) {
+          // It's the old silly sid.
+          (Some(value), None)
+        }
+        else {
+          // It's the new fancy sid (Base64, no '.'), not the old silly sid.
+          (None, Some(value))
+        }
+    }
+
+    val anySillySid = anySillySidInCookie orElse anySillySidInHeader
+
+    // New better sid:  [btr_sid]
+    val anyFancySidPart123CookieVal = urlDecodeCookie(SessionIdPart123CookieName, request)
+    val anyFancySidPart12Maybe3: Opt[St] =
+                            anyFancySidPart123CookieVal orElse anyFancySidPart12Maybe3InHeader
+    val anyFancySidPart4: Opt[St] = urlDecodeCookie(SessionIdPart4HttpOnlyCookieName, request)
+    val anyFancySidPart5: Opt[St] = urlDecodeCookie(SessionIdPart5StrictCookieName, request)
 
     val now = globals.now()
 
-    val sessionIdStatus: SidStatus =
-          anySessionId.map(
-            checkSessionId(siteId, _, now, expireIdleAfterMillis = expireIdleAfterMillis)
-            ) getOrElse SidAbsent
+    val checkSidResult: CheckSidResult =
+          checkSessionId(
+                site,
+                anySillySid = anySillySid,
+                anyFancySidPart12Maybe3 = anyFancySidPart12Maybe3,
+                anyFancySidPart4 = anyFancySidPart4,
+                anyFancySidPart5 = anyFancySidPart5,
+                dao,
+                now,
+                expireIdleAfterMillis = expireIdleAfterMillis)
+
+    val sessionIdStatus = checkSidResult.sidStatus
+    val upgrToFancySidCookies = checkSidResult.createCookies
+    val deleteFancySidCookies = checkSidResult.discardCookies  // rename? deleteFancySidCookies
+
+
+    // ----- Check xsrf token
 
     // On GET requests, simply accept the value of the xsrf cookie.
     // (On POST requests, however, we check the xsrf form input value)
     val anyXsrfCookieValue = urlDecodeCookie(XsrfCookieName, request)
 
     val isGet = request.method == "GET"
     val isPost = request.method == "POST"
     val cookies = request.cookies // nice to see in debugger
-    val maybeCredentials = cookies.nonEmpty ||
-          // There's also the session id header, SessionIdHeaderName
-          // (for embedded discussions, cookies then usually won't work).
-          sessionIdStatus != SidAbsent
+    val maybeCredentials = cookies.nonEmpty || anySidHeaderValue.isDefined
+    val definitelyNoCreds = !maybeCredentials
+    dieIf(definitelyNoCreds && sessionIdStatus != SidAbsent,
+          "TyE50RMEG24", s"No creds, still, session id != SidAbsent: $sessionIdStatus")
 
-    val sidXsrfNewCookies: (SidStatus, XsrfOk, List[Cookie]) =
+    val (xsrf, newCookies): (XsrfOk, List[Cookie]) = {
       if (isGet || skipXsrfCheck) {
         // Accept this request, and create new XSRF token if needed.
         // Don't throw anything (for now at least). [GETNOTHROW]
 
         if (!sessionIdStatus.canUse && !sessionIdStatus.isInstanceOf[SidExpired])
@@ -394,12 +484,11 @@ class EdSecurity(globals: Globals) {
           else {
             val newXsrfOk = createXsrfToken()
             val cookie = urlEncodeCookie(XsrfCookieName, newXsrfOk.value)
             (newXsrfOk, List(cookie))
           }
-
-        (sessionIdStatus, xsrfOk, anyNewXsrfCookie)
+        (xsrfOk, anyNewXsrfCookie)
       }
       else if (!isPost) {
         // Sometimes people do `curl -I http://...` which sends a HEAD
         // request — but only GET and POST, sometimes OPTIONS,
         // are allowed.  It's nice, though, if in the X-Error-Code response
@@ -409,16 +498,17 @@ class EdSecurity(globals: Globals) {
         val details = s"Request method not allowed: ${request.method}"
         throwForbiddenIf(request.method == "HEAD",
               "TyE_REQUEST_METHOD__HEAD__NOT_ALLOWED", details)
         throwForbidden( "TyE_REQUEST_METHOD_NOT_ALLOWED", details)
       }
-      else if (isPost && !maybeCredentials) {
+      else if (isPost && definitelyNoCreds) {
         // This might be from a backend server, fetching publicly available data.
         // Example: An Electron or iOS app, calling /-/v0/search, to show
         // in-app help.
         // No credentials are included in the request, so there's no xsrf risk.
-        (SidAbsent, XsrfOk("_no_creds_"), Nil)
+        dieIf(sessionIdStatus != SidAbsent, "TyE502MWEG")
+        (XsrfOk(""), Nil)
       }
       else {
         // Reject this request if the XSRF token is invalid,
         // or if the SID is corrupt (but not if simply absent).
 
@@ -439,16 +529,22 @@ class EdSecurity(globals: Globals) {
               None
           }
         } getOrElse
             throwForbidden("TyE0XSRFTKN_", "No xsrf token")
 
+        val gotAnyCookie =
+              anySillySidInCookie.isDefined ||
+              anyFancySidPart123CookieVal.isDefined ||
+              anyFancySidPart4.isDefined ||
+              anyFancySidPart5.isDefined
+
         val xsrfOk = {
           val xsrfStatus =
-            checkXsrfToken(
-              xsrfToken, anyXsrfCookieValue,
-              thereIsASidCookie = anySessionIdCookieValue.isDefined,
-              now, expireIdleAfterMillis = expireIdleAfterMillis)
+                checkXsrfToken(
+                    xsrfToken, anyXsrfCookieValue,
+                    thereIsASidCookie = gotAnyCookie,
+                    now, expireIdleAfterMillis = expireIdleAfterMillis)
 
           def helpText(theProblem: String, nowHaveOrWillGet: String): String = i"""
             |Security issue: $theProblem. Please try again:
             |  - Click any button you just clicked, again.
             |  - Or reload the page.
@@ -509,28 +605,36 @@ class EdSecurity(globals: Globals) {
           }
 
           xsrfStatus.asInstanceOf[XsrfOk]
         }
 
-        CLEAN_UP // simplify this weird match-case!  & don't take & "return"
-        // sessionIdStatus for no reason all the time.
-        val r = sessionIdStatus match {
-          case s: SidOk => (s, xsrfOk, Nil)
-          case SidAbsent => (SidAbsent, xsrfOk, Nil)
-          case s: SidExpired => (s, xsrfOk, Nil)
-          case _ =>
+        sessionIdStatus match {
+          case SidBadFormat | SidBadHash =>
             throw ResultException(
               ForbiddenResult("TyEBADSID", "Bad session ID",
                   "You can try again — I just deleted the bad session ID.")
                 .discardingCookies(
-                  DiscardingSessionCookie))
+                      DiscardingSessionCookies: _*))
+          case _ => // fine
         }
-        dieIf(isDevOrTest && r != (sessionIdStatus, xsrfOk, Nil), "TyE205RKPG36")
-        r
+
+        (xsrfOk, Nil)
       }
+    }
+
+    dieIf(upgrToFancySidCookies.nonEmpty && deleteFancySidCookies.nonEmpty, "TyE5A6MRE25")
+
+    val allCookiesToAdd =
+          if (!maySetCookies) Nil
+          else upgrToFancySidCookies:::newCookies
 
-    sidXsrfNewCookies
+    CheckSidAndXsrfResult(
+          checkSidResult.anyTySession,  // [btr_sid]
+          sessionIdStatus,  // old, will remove later
+          xsrf,
+          cookiesToAdd = allCookiesToAdd,
+          cookiesToDelete = deleteFancySidCookies)
   }
 
 
   private val MinOwnerPasswordLength = 10
 
@@ -673,99 +777,405 @@ class EdSecurity(globals: Globals) {
     COULD_OPTIMIZE // skip the secure hash, when using the Double Submit Cookie pattern [4AW2J7].
     XsrfOk(timeDotRandomDotHash())  // [2AB85F2]
   }
 
 
-  def createSessionIdAndXsrfToken(siteId: SiteId, userId: UserId): (SidOk, XsrfOk, List[Cookie]) = {
-    COULD_OPTIMIZE // pass settings or a dao to here instead? so won't need to create this 2nd one.
-                    // (the caller always already has one)
-    val dao = globals.siteDao(siteId)
+
+  def createSessionIdAndXsrfToken(req: AuthnReqHeader, userId: PatId)
+        : (SidOk, XsrfOk, List[Cookie]) = {
+
+    AUDIT_LOG // Maybe log session id creation?
+
+    val site = req.site
+    val dao = req.dao
+
+    val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
+    val useFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
 
     val ppt = dao.getParticipant(userId)
     throwForbiddenIf(ppt.exists(_.isGroup), "TyELGIGRP", "Cannot login as a group")  // [imp-groups]
 
     val settings = dao.getWholeSiteSettings()
     val expireIdleAfterSecs = settings.expireIdleAfterMins * 60
 
-    // Note that the xsrf token is created using the non-base64 encoded cookie value.
-    val sidOk = createSessionId(siteId, userId)
     val xsrfOk = createXsrfToken()
-    UX; SECURITY; SHOULD // use HttpOnly cookies — otherwise Safari will delete the cookie
-    // after 7 days. See: https://webkit.org/blog/8613/intelligent-tracking-prevention-2-1/
-    // the "Client-Side Cookies Capped to 7 Days of Storage", section, and sub section
-    // "Will This Change Log Users Out?" — cookies that are Secure and HttpOnly aren't deleted.
-    // This means the client can no longer look at the session cookie, to find out if one is
-    // logged in? Could add JS variables instead.  [NOCOOKIES]
+    val xsrfCookie = urlEncodeCookie(XsrfCookieName, xsrfOk.value,
+          maxAgeSecs = Some(expireIdleAfterSecs + XsrfAliveExtraSeconds))
+
+    // New better sid  [btr_sid]
+    // ----------------------------------------
+
+    if (tryFancySid || useFancySid) {
+      val (newSidCookies, session) = genAndSaveFancySid(req, patId = userId,
+            expireIdleAfterSecs = expireIdleAfterSecs, dao.now(),
+            dao.asInstanceOf[SessionSiteDaoMixin])
+      val sidOk = SidOk(session.part1And2, ageInMillis = 0, Some(userId))
+      return (sidOk, xsrfOk, xsrfCookie :: newSidCookies)
+    }
+
+    // Old style sid  (signed cookie)
+    // ----------------------------------------
+
+    var sidOk = createSessionId(site, userId)
     val sidCookie = urlEncodeCookie(SessionIdCookieName, sidOk.value,
       maxAgeSecs = Some(expireIdleAfterSecs))
-    val xsrfCookie = urlEncodeCookie(XsrfCookieName, xsrfOk.value,
-      maxAgeSecs = Some(expireIdleAfterSecs + XsrfAliveExtraSeconds))
+
+    COULD_OPTIMIZE // use ArrBuf
     (sidOk, xsrfOk, sidCookie::xsrfCookie::Nil)
   }
 
 
-  private val HashLength: Int = 15
-  private def secretSalt = globals.applicationSecret
+  private def genAndSaveFancySid(req: AuthnReqHeader, patId: PatId, expireIdleAfterSecs: i32,
+          now: When, dao: SessionSiteDaoMixin): (List[Cookie], TySession) = {
+
+    import com.debiki.core.{TySession => S}
+    val totalEntropy = S.SidLengthCharsTotal * S.SidEntropyPerChar
+    assert(totalEntropy == 96 + 144 + 144 + 144 + 96)
+    val wholeSid = nextRandomString(totalEntropy, base36 = false, base64UrlSafe = true)
+    dieIf(wholeSid.length != S.SidLengthCharsTotal, "TyESIDLEN538RMD",
+          s"Generated a ${wholeSid.length} chars session id, but should be ${
+          S.SidLengthCharsTotal} chars long. Here it is: '$wholeSid' (won't get used)")
+
+    val lenUpTo1 = S.SidLengthCharsPart1
+    val lenUpTo2 = lenUpTo1 + S.SidLengthCharsPart2
+    val lenUpTo3 = lenUpTo2 + S.SidLengthCharsPart3
+    val lenUpTo4 = lenUpTo3 + S.SidLengthCharsPart4
+
+    val part1 = wholeSid.substring(0, lenUpTo1)
+    val part2 = wholeSid.substring(lenUpTo1, lenUpTo2)
+    val part3 = wholeSid.substring(lenUpTo2, lenUpTo3)
+    val part4 = wholeSid.substring(lenUpTo3, lenUpTo4)
+    val part5 = wholeSid.substring(lenUpTo4, S.SidLengthCharsTotal)
+
+    assert(part1.length == S.SidLengthCharsPart1)
+    assert(part2.length == S.SidLengthCharsPart2)
+    assert(part3.length == S.SidLengthCharsPart3)
+    assert(part4.length == S.SidLengthCharsPart4)
+    assert(part5.length == S.SidLengthCharsPart5)
+
+    val newSidPart123Cookie = urlEncodeCookie(
+          SessionIdPart123CookieName, part1 + part2 + part3,
+          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = false)
+
+    val newSidPart4Cookie = urlEncodeCookie(
+          SessionIdPart4HttpOnlyCookieName, part4,
+          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = true)
+
+    val newSidPart5Cookie = urlEncodeCookie(
+          SessionIdPart5StrictCookieName, part5,
+          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = true, sameSiteStrict = true)
+
+    val startHeaders = {
+      import http.{HeaderNamesLowercase => H}
+      // New & nice:
+      val chUserAgent = req.headers.get(H.ClientHintUserAgent).trimNoneIfBlank
+      val chUaMobile = req.headers.get(H.ClientHintUaMobile).trimNoneIfBlank
+      val chUaPlatform = req.headers.get(H.ClientHintUaPlatform).trimNoneIfBlank
+      // Old & verbose:
+      val userAgent = req.headers.get(H.UserAgent).trimNoneIfBlank
+
+      val mapBuilder = Map.newBuilder[St, JsValue]
+      val maxLen = 200 // header max length. 200 is a lot?
+      val mobLen = 20  // should be just "?0" or "?1"
+      chUserAgent.foreach(v => mapBuilder += H.ClientHintUserAgent -> JsString(v take maxLen))
+      chUaMobile.foreach(v => mapBuilder += H.ClientHintUaMobile -> JsString(v take mobLen))
+      chUaPlatform.foreach(v => mapBuilder += H.ClientHintUaPlatform -> JsString(v take maxLen))
+
+      // Skip the verbose User-Agent header if we got the new & better client hint header.
+      if (chUserAgent.isEmpty) {
+        userAgent.foreach(v => mapBuilder += H.UserAgent -> JsString(v take maxLen))
+      }
+      JsObject(mapBuilder.result)
+    }
+
+    val session = TySession(
+          patId = patId,
+          createdAt = now,
+          version = TySession.CurVersion,
+          startIp = Some(req.ip),
+          startBrowserId = req.browserId.map(_.cookieValue),
+          startHeaders = startHeaders,
+          part1CompId = part1,
+          part2ForEmbgStorage = part2,
+          part2Hash = hashSha512FirstHalf32Bytes(part2),
+          part3ForDirJs = Some(part3),
+          part3Hash = hashSha512FirstHalf32Bytes(part3),
+          part4HttpOnly = Some(part4),
+          part4Hash = hashSha512FirstHalf32Bytes(part4),
+          part5Strict = Some(part5),
+          part5Hash = hashSha512FirstHalf32Bytes(part5))
+
+    dao.insertValidSession(session)
+
+    (List(newSidPart123Cookie, newSidPart4Cookie, newSidPart5Cookie), session)
+  }
 
 
-  private def checkSessionId(siteId: SiteId, value: String, now: When, expireIdleAfterMillis: Long)
-      : SidStatus = {
+  // 15 chars is 90 bits entropy (15 * 6 bits, using Base64) — that's more than enough:
+  // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
+  // >  The session ID value must provide at least 64 bits of entropy
+  // 11 chars is 66 bits entropy (with a perfect rand num gen).
+  private val HashLength: i32 = 15
+
+  private def secretSalt: St = globals.applicationSecret
+
+
+  private def checkSessionId(
+        site: SiteBrief,
+        anySillySid: Opt[St],
+        anyFancySidPart12Maybe3: Opt[St],
+        anyFancySidPart4: Opt[St],
+        anyFancySidPart5: Opt[St],
+        dao: SessionSiteDaoMixin,
+        now: When,
+        expireIdleAfterMillis: i64): CheckSidResult = {
+
+    val hasFancySid = anyFancySidPart12Maybe3.isDefined || anyFancySidPart4.isDefined ||
+          anyFancySidPart5.isDefined
+    val useOnlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
+    val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
+
+    if (useOnlyFancySid || (tryFancySid && hasFancySid)) {
+      var result = checkFancySessionId(anyPart12Maybe3 = anyFancySidPart12Maybe3,
+            anyPart4 = anyFancySidPart4, anyPart5 = anyFancySidPart5,
+            dao, now, expireIdleAfterMillis = expireIdleAfterMillis)
+      if (anySillySid.isDefined) {
+        result = result.copy(
+              discardCookies = DiscardingSillySidCookie :: result.discardCookies)
+      }
+      result
+    }
+    else {
+      var result = checkSillySessionId(site, anySillySid, dao, now, expireIdleAfterMillis)
+      if (hasFancySid) {
+        result = result.copy(
+              discardCookies = DiscardingFancySidCookies ::: result.discardCookies)
+      }
+      result
+    }
+  }
+
+
+  private def checkFancySessionId(  // [btr_sid]
+        anyPart12Maybe3: Opt[St], anyPart4: Opt[St], anyPart5: Opt[St],
+        dao: SessionSiteDaoMixin, now: When,
+        expireIdleAfterMillis: i64): CheckSidResult = {
+
+    val anySession = anyPart12Maybe3 flatMap dao.getSessionByPart1
+
+    val session: TySessionInDbMaybeBad = anySession getOrElse {
+      // If pat has logged out client side, when offline, the not-HttpOnly session parts
+      // would be gone, whilst we'd still get the 4th and 5th parts in HttpOnly cookies.
+      // If so, we should delete the session server side too —
+      // so, let's look it up by part 4 — there's an index on part 4 (not part 5), because
+      // part 4 should always be present, if part 5 is present (part 4 is SameSite Lax,
+      // part 5 Strict).
+
+      // Maybe maybe Safari will sometimes mistakenly auto clear localStorage?
+      // See [ios_itp] in maybe-later.txt.
+
+      anyPart4.flatMap(dao.getSessionByPart4HttpOnly(_, maybeActiveOnly = true)) foreach { s =>
+        // Test: sso-test  TyT4ABKRW0268.TyTESESS123GONE
+        AUDIT_LOG // client side logout
+        val sessionDeleted = s.copy(deletedAt = Some(now))
+        dao.updateSession(sessionDeleted)
+      }
+
+      // Session gone. Delete any cookies.
+      val gotCookies = anyPart12Maybe3.isDefined || anyPart4.isDefined || anyPart5.isDefined
+
+      return CheckSidResult.noSession(
+            SidAbsent,
+            discardCookies = if (gotCookies) DiscardingFancySidCookies else Nil)
+    }
+
+    import TySession._
+
+    val thePart12Maybe3: St = anyPart12Maybe3 getOrDie "TyE3MG70QFM2"
+    val thePart2: St = thePart12Maybe3.substring(SidLengthCharsPart1, SidLengthCharsPart12)
+    val hashPart2: Array[i8] = hashSha512FirstHalf32Bytes(thePart2)
+    val part3IsPresent = thePart12Maybe3.length > TySession.SidLengthCharsPart12
+    val anyPart3: Opt[St] =
+          if (!part3IsPresent) None
+          else Some(thePart12Maybe3.substring(SidLengthCharsPart12, SidLengthCharsPart123))
+
+    val anyPart3Hash = anyPart3.map(hashSha512FirstHalf32Bytes)
+    val anyPart4Hash = anyPart4.map(hashSha512FirstHalf32Bytes)
+    val anyPart5Hash = anyPart5.map(hashSha512FirstHalf32Bytes)
+
+    // Part 2, and 3, 4, 5 if present, must be from the same session.
+    // We've checked part 1 already — we found the session via part 1.
+
+    val badPart2 = !hashPart2.sameElements(session.part2HashForEmbgStorage)
+    val badPart3 = anyPart3Hash.map(_ sameElements session.part3HashForDirJs) is false
+    val badPart4 = anyPart4Hash.map(_ sameElements session.part4HashHttpOnly) is false
+    val badPart5 = anyPart5Hash.map(_ sameElements session.part5HashStrict) is false
+
+    if (badPart2 || badPart3 || badPart4 || badPart5) {
+      AUDIT_LOG // this is suspicious?
+
+      val sessionDeleted = session.copy(deletedAt = Some(now))
+      dao.updateSession(sessionDeleted)
+
+      // Maybe part 4 is from another older/newer session somehow? That'd be weird.
+      // Then, invalidate that session too.
+      // (Since parts 123 are not-HttpOnly, but parts 4 and 5 are HttpOnly,
+      // they could get out of sync maybe because of some unknown bug, or if a script
+      // or a person manipulates their cookies.)
+      if (badPart4) {
+        COULD_OPTIMIZE // We've hashed part 4 above, need not do again in
+        // SessionsRdbMixin.loadSession().
+        dao.getSessionByPart4HttpOnly(anyPart4.get, maybeActiveOnly = true) foreach {
+              differentSession =>
+          // The database should prevent any parts being the same (unique indexes,
+          // on parts 1 and 4, but not 2, 3 or 5).
+          AUDIT_LOG // that we're deleting another session?
+          UNTESTED ; TESTS_MISSING  // TyTSESSHALFBAD
+
+          warnDevDieIf(differentSession.part1CompId == session.part1CompId, "TyE603MSEJ56")
+          warnDevDieUnless(differentSession.part4HashHttpOnly sameElements
+                session.part4HashHttpOnly, "TyE603MSEJ56")
+
+          val differentSessionDeleted = differentSession.copy(deletedAt = Some(now))
+          dao.updateSession(differentSessionDeleted)
+        }
+      }
+
+      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)
+    }
+
+    if (session.isDeleted || session.hasExpired)
+      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)
+
+    // Did the session expire just now?  [lazy_expire_sessions]
+    //
+    // Maybe instead:
+    // val justExpired = session.expiresNow(now, expireIdleAfterMillis / MillisPerMinute)
+
+    UX; SHOULD // bump lastSeenAt somehow, and add idle time to that? [bump_sid_last_use]
+
+    val expiresAt = expireIdleAfterMillis + session.createdAt.millis
+    if (expiresAt < now.millis) {
+      AUDIT_LOG // session lazy-expired
+      // Then don't allow using the session any more, even if the server time gets
+      // changed back to before when it expired.
+      val sessionExpired = session.copy(expiredAt = Some(now))
+      dao.updateSession(sessionExpired)
+      // or use SidExpired instead of SidAbsent. But why?
+      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)
+    }
+
+    val sidOk = SidOk(value = thePart12Maybe3.take(SidLengthCharsPart12),
+          ageInMillis = now.millis - session.createdAt.millis,
+          userId = Some(session.patId))
+
+    CheckSidResult(
+          Some(session.copyAsValid(
+              // Parts 3, 4, 5 are optional. (Parts 1 and 2? We found the session via part 1,
+              // and part 2 is required, and we have compared it with the session
+              // in the database already, above.)
+              part2 = thePart2,
+              part3 = anyPart3,
+              part4 = anyPart4,
+              part5 = anyPart5)),
+          sidOk)
+  }
+
+
+  private def checkSillySessionId(site: SiteBrief, anyOldSid: Opt[St],
+        dao: SessionSiteDaoMixin, now: When,
+        expireIdleAfterMillis: i64): CheckSidResult = {
+
+    val value = anyOldSid getOrElse {
+      return CheckSidResult.noSession(SidAbsent)
+    }
+
     // Example value: 88-F7sAzB0yaaX.1312629782081.1c3n0fgykm  - no, obsolete
-    if (value.length <= HashLength) return SidBadFormat
+    if (value.length <= HashLength)
+      return CheckSidResult.noSession(SidBadFormat)
+
     val (hash, dotUseridDateRandom) = value splitAt HashLength
     val realHash = hashSha1Base64UrlSafe(
-      s"$secretSalt.$siteId$dotUseridDateRandom") take HashLength
-    if (hash != realHash) return SidBadHash
-    dotUseridDateRandom.drop(1).split('.') match {
+      s"$secretSalt.${site.id}$dotUseridDateRandom") take HashLength
+
+    if (hash != realHash)
+      return CheckSidResult.noSession(SidBadHash)
+
+    val oldOkSid = dotUseridDateRandom.drop(1).split('.') match {
       case Array(userIdString, dateStr, randVal) =>
         val userId: Option[UserId] =
           if (userIdString.isEmpty) None
           else Try(userIdString.toInt).toOption orElse {
-            return SidBadFormat
+            return CheckSidResult.noSession(SidBadFormat)
           }
         val ageMillis = now.millis - dateStr.toLong
         UX; BUG; COULD; // [EXPIREIDLE] this also expires *active* sessions. Instead,
         // lookup the user, and consider only time elapsed, since hens last visit.
         // ... Need to have a SiteDao here then. And pass the Participant back to the
         // caller, so it won't have to look it up again.
         // Not urgent though — no one will notice: by default, one stays logged in 1 year [7AKR04].
-        if (ageMillis > expireIdleAfterMillis)
-          return SidExpired(
-            minutesOld = ageMillis / MillisPerMinute,
-            maxAgeMins = expireIdleAfterMillis / MillisPerMinute)
+        if (ageMillis > expireIdleAfterMillis) {
+          val expiredSid = SidExpired(
+                  minutesOld = ageMillis / MillisPerMinute,
+                  maxAgeMins = expireIdleAfterMillis / MillisPerMinute,
+                  wasForPatId = userId)
+          return CheckSidResult.noSession(expiredSid)
+        }
         SidOk(
           value = value,
           ageInMillis = ageMillis,
           userId = userId)
       case _ => SidBadFormat
     }
+
+    var newSidCookies: List[Cookie] = Nil
+
+    // Upgrade old sid to new style sid:  [btr_sid]
+    // ----------------------------------------
+
+    /* Maybe skip this. Hard to test?
+    if ((tryFancySid || useFancySid) && oldOkSid.userId.isDefined) {
+      val dao = anyDao getOrDie "TyE50FREN68"
+      val patId = oldOkSid.userId getOrDie "TyE602MTEGPH"
+      val settings = dao.getWholeSiteSettings()
+      val expireIdleAfterSecs = settings.expireIdleAfterMins * 60
+      val (newCookies, sidPart1, sidPart2) =
+            genAndSaveFancySid(patId = patId, expireIdleAfterSecs, dao.redisCache,
+                isOldUpgraded = true)
+      // cookies = newSidPart1Cookie::newSidPart2Cookie::cookies
+      result = SidOk(sidPart1,
+            expireIdleAfterSecs * 1000, Some(patId))
+      newSidCookies = newCookies
+    }
+    */
+
+    CheckSidResult(anyTySession = None, oldOkSid, createCookies = newSidCookies)
   }
 
 
-  private def createSessionId(siteId: SiteId, userId: UserId): SidOk = {
-    // For now, create a SID value and *parse* it to get a SidOk.
-    // This is stupid and inefficient.
+  @deprecated("Now", "Use the fancy session id instead.")
+  private def createSessionId(site: SiteBrief, userId: PatId): SidOk = {
     val now = globals.now()
-    val uid = "" // for now
     val useridDateRandom =
          userId +"."+
          now.millis +"."+
          (nextRandomString() take 10)
+
     // If the site id wasn't included in the hash, then an admin from site A could
     // login as admin at site B, if they have the same user id and username.
     val saltedHash = hashSha1Base64UrlSafe(
-      s"$secretSalt.$siteId.$useridDateRandom") take HashLength
-    val value = s"$saltedHash.$useridDateRandom"
+      s"$secretSalt.${site.id}.$useridDateRandom") take HashLength
 
-    checkSessionId(siteId, value, now, expireIdleAfterMillis = Long.MaxValue).asInstanceOf[SidOk]
+    val value = s"$saltedHash.$useridDateRandom"
+    SidOk(value, ageInMillis = 0, Some(userId))
   }
 
 
   // ----- Secure cookies
 
-  def SecureCookie(name: String, value: String, maxAgeSeconds: Option[Int] = None,
-        httpOnly: Boolean = false) =
+  def SecureCookie(name: St, value: St, maxAgeSeconds: Opt[i32] = None,
+        httpOnly: Bo = false): Cookie =
     Cookie(
       name,
       value,
       maxAge = maxAgeSeconds,
       secure = globals.secure,
@@ -781,10 +1191,12 @@ class EdSecurity(globals: Globals) {
     * Ok explanation of Strict, Lax and None:
     *   https://web.dev/samesite-cookies-explained/
     */
   private def anySameSiteCookieValue(): Option[Cookie.SameSite] = {  // [SAMESITE]
     // SameSite.None only works with https.
+    SECURITY // Remove this? No longer needed — instead, session parts 1-5,
+    // and 5 is always strict.
     if (globals.secure && globals.config.sameSiteNone) {
       Some(Cookie.SameSite.None)
     }
     else if (globals.config.sameSiteLax) {
       Some(Cookie.SameSite.Lax)
@@ -796,40 +1208,61 @@ class EdSecurity(globals: Globals) {
 
 
   def DiscardingSecureCookie(name: String) =
     DiscardingCookie(name, secure = globals.secure)
 
-  def DiscardingSessionCookie: DiscardingCookie = DiscardingSecureCookie(SessionIdCookieName)
+  def DiscardingSessionCookies: List[DiscardingCookie] =
+    List(DiscardingSecureCookie(SessionIdCookieName),
+        DiscardingSecureCookie(SessionIdPart123CookieName),
+        DiscardingSecureCookie(SessionIdPart4HttpOnlyCookieName),
+        DiscardingSecureCookie(SessionIdPart5StrictCookieName))
+
+  def DiscardingSillySidCookie: DiscardingCookie =
+    DiscardingSecureCookie(SessionIdCookieName)
+
+  def DiscardingFancySidCookies: List[DiscardingCookie] =
+    List(DiscardingSecureCookie(SessionIdPart123CookieName),
+        DiscardingSecureCookie(SessionIdPart4HttpOnlyCookieName),
+        DiscardingSecureCookie(SessionIdPart5StrictCookieName))
+
   // Maybe also always delete:  ImpersonationCookieName  ?
   // Well, things work fine anyway as of now (Mars 2020).
 
   // Two comments on the encoding of the cookie value:
   // 1. If the cookie contains various special characters
   // (whitespace, any of: "[]{]()=,"/\?@:;") it will be
   // sent as a Version 1 cookie (by javax.servlet.http.Cookie),
   // then it is surrounded with quotes.
   // the jQuery cookie plugin however expects an urlencoded value:
   // 2. urlEncode(value) results in these cookies being sent:
-  //    Set-Cookie: dwCoUserEmail="kajmagnus79%40gmail.com";Path=/
-  //    Set-Cookie: dwCoUserName="Kaj%20Magnus";Path=/
+  //    Set-Cookie: dwCoUserEmail="someone%40exaple.com";Path=/
+  //    Set-Cookie: dwCoUserName="space%20text";Path=/
   // No encoding results in these cookies:
-  //    Set-Cookie: dwCoUserEmail=kajmagnus79@gmail.com;Path=/
-  //    Set-Cookie: dwCoUserName="Kaj Magnus";Path=/
+  //    Set-Cookie: dwCoUserEmail=someone@example.com;Path=/
+  //    Set-Cookie: dwCoUserName="space text";Path=/
   // So it seems a % encoded string is surrounded with double quotes, by
   // javax.servlet.http.Cookie? Why? Not needed!, '%' is safe.
   // So I've modified jquery-cookie.js to remove double quotes when
   // reading cookie values.
-  private def urlEncodeCookie(name: String, value: String, maxAgeSecs: Option[Int] = None) =
+  private def urlEncodeCookie(name: St, value: St, maxAgeSecs: Opt[i32] = None,
+        httpOnly: Bo = false, sameSiteStrict: Bo = false) =
     Cookie(
       name = name,
       value = urlEncode(convertEvil(value)),  // see comment above
       maxAge = maxAgeSecs,
       path = "/",
+      // Don't set — if set to, say, example.com, then, vulnerabilities at
+      // www.example.com might allow an attacker to get access to cookies
+      // from secure.example.com
+      // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#domain-and-path-attributes
       domain = None,
       secure = globals.secure,
-      sameSite = anySameSiteCookieValue(),
-      httpOnly = false)
+      sameSite =
+            // If debugging on localhost over http, does Strict work?
+            if (sameSiteStrict) Some(Cookie.SameSite.Strict)
+            else anySameSiteCookieValue(),
+      httpOnly = httpOnly)
 
 
   /** Extracts any browser id cookie from the request, or creates it if absent
     * and is a POST request.
     */
diff --git a/appsv/server/talkyard/server/JsX.scala b/appsv/server/talkyard/server/JsX.scala
index 4cb403931..66c587f2d 100644
--- a/appsv/server/talkyard/server/JsX.scala
+++ b/appsv/server/talkyard/server/JsX.scala
@@ -308,10 +308,28 @@ object JsX {   RENAME // to JsonPaSe
 
     userJson
   }
 
 
+  /// ts: Session
+  def JsSession(sess: TySessionInDbMaybeBad, inclPart1: Bo = true): JsObject = {
+    // Don't include the actual session id. (That is, exclude parts 2 – 5. *Could* maybe
+    // theoretically include them, since they're hashes, but bad idea, and not needed.)
+    var json = Json.obj(
+          "patId" -> sess.patId,
+          "createdAt" -> JsWhenMs(sess.createdAt),
+          "deletedAt" -> JsWhenMsOrNull(sess.deletedAt),
+          "expiredAt" -> JsWhenMsOrNull(sess.expiredAt),
+          "version" -> JsNumber(sess.version),
+          "startHeaders" -> sess.startHeaders)
+    if (inclPart1) {
+      json += "part1" -> JsString(sess.part1CompId)
+    }
+    json
+  }
+
+
   def JsUserStats(stats: UserStats, isStaffOrSelf: Boolean): JsObject = {
     val tourTipsIds: immutable.Seq[String] = stats.tourTipsSeen getOrElse Nil
     var result = Json.obj(
       "userId" -> stats.userId,
       "lastSeenAt" -> JsWhenMs(stats.lastSeenAt),
diff --git a/appsv/server/talkyard/server/api/QueryDoController.scala b/appsv/server/talkyard/server/api/QueryDoController.scala
index 730120e89..8519473d2 100644
--- a/appsv/server/talkyard/server/api/QueryDoController.scala
+++ b/appsv/server/talkyard/server/api/QueryDoController.scala
@@ -79,12 +79,12 @@ class QueryDoController @Inject()(cc: ControllerComponents, edContext: EdContext
       val doWhatSt = parseOptSt(jsOb, "doWhat")
       val getQueryJsOb = parseOptJsObject(jsOb, "getQuery")
       val listQueryJsOb = parseOptJsObject(jsOb, "listQuery")
       val searchQueryJsOb = parseOptJsObject(jsOb, "searchQuery")
       // Any nested queries or actions? (E.g. for fine grained transaction control.)
-      val nestedQueries: Opt[JsArray] = parseOptJsArray(jsVal, "manyQueries")
-      val nestedActions: Opt[JsArray] = parseOptJsArray(jsVal, "doActions")
+      val nestedQueries: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "manyQueries")
+      val nestedActions: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "doActions")
 
       val anyQueryDefined =
             getQueryJsOb.isDefined || listQueryJsOb.isDefined ||
             searchQueryJsOb.isDefined || nestedQueries.isDefined
 
diff --git a/appsv/server/talkyard/server/authn/SsoAuthnController.scala b/appsv/server/talkyard/server/authn/SsoAuthnController.scala
index 3306381d1..a5021e1a2 100644
--- a/appsv/server/talkyard/server/authn/SsoAuthnController.scala
+++ b/appsv/server/talkyard/server/authn/SsoAuthnController.scala
@@ -102,18 +102,18 @@ class SsoAuthnController @Inject()(cc: ControllerComponents, edContext: EdContex
         // The Sysbot user should only do things based API requests.
         throwForbiddenIf(userId == SysbotUserId, "TyELGIBOT", "Cannot login as Sysbot.")
 
         val user = dao.getTheUser(userId)
         dao.pubSub.userIsActive(siteId, user, request.theBrowserIdData)
-        val (sid, _, sidAndXsrfCookies) = security.createSessionIdAndXsrfToken(siteId, user.id)
+        val (sid, _, sidAndXsrfCookies) =
+              security.createSessionIdAndXsrfToken(request, user.id)
 
         val response = if (request.isAjax) {
           // As of 2019-12: This is embedded comments login, when 3rd party cookies blocked. [306KUD244]
           SECURITY // a session cookie will get attached too — would be good if it could
           // be deleted server side. [serversid]
           OkSafeJson(Json.obj(
-            // Not yet weak but later. [weaksid]
             "weakSessionId" -> JsString(sid.value)))  // [NOCOOKIES]
         }
         else {
           // Remove server origin, so one cannot somehow get redirected to a phishing website
           // (if one follows a link with an "evil" go-next url param to the SSO login page,
@@ -239,15 +239,14 @@ class SsoAuthnController @Inject()(cc: ControllerComponents, edContext: EdContex
 
     val (user, _) =
           upsertUser(extUser, req, mayOnlyInsertNotUpdate = true)
 
     val (sid, _, _) =
-          security.createSessionIdAndXsrfToken(siteId, user.id)
+          security.createSessionIdAndXsrfToken(req, user.id)
 
     OkSafeJson(Json.obj(
-      // Not yet weak but later. [weaksid]  [NOCOOKIES]
-      "weakSessionId" -> JsString(sid.value)))
+      "weakSessionId" -> JsString(sid.value)))  // [NOCOOKIES]
   }
 
 
 
   def apiv0_upsertUserGenLoginSecret: Action[JsValue] =
diff --git a/appsv/server/talkyard/server/authn/package.scala b/appsv/server/talkyard/server/authn/package.scala
index 85635c150..a7dc7fe95 100644
--- a/appsv/server/talkyard/server/authn/package.scala
+++ b/appsv/server/talkyard/server/authn/package.scala
@@ -31,10 +31,86 @@ package object authn {   REFACTOR; MOVE // most of this to an object UserInfoPar
     case object LoginBecauseNotFound extends LoginReason(21)
     case object AuthnRequiredToRead extends LoginReason(22)
     case object LoginToAdministrate extends LoginReason(24)
   }
 
+  sealed abstract class MinAuthnStrength(val IntVal: i32, val fullSidRequired: Bo = true) {
+    def toInt: i32 = IntVal
+  }
+
+
+  // RENAME to AuthnStrength? And a *param* name can be minAuthnStrength instead?
+  // Then, AuthnStrength.[InternalJob] makes sense, otherwise not.
+  //
+  object MinAuthnStrength {
+
+    // No session id needed — instead, we check an e2e test secret, for each request.
+    case object E2eTestPassword extends MinAuthnStrength(10, fullSidRequired = false)
+
+    // No session id needed — instead, we check an API secret, for each request.
+    case object ApiSecret extends MinAuthnStrength(15, fullSidRequired = false)
+
+    /// Parts 1 and 2 of old embedded sessions might have been cached in localStorage
+    /// in the embedding website, and if the embedding website has security vulnerabilities,
+    /// an attacker might have gained access to those parts of the session.
+    /// Therefore, those parts of the session should give access only to
+    /// embedded comments — like, posting comments, editing one's comments,
+    /// or (for mods) moderating embedded comments [mod_emb_coms_sid],
+    /// but nothing else. Then, if a blog has security vulnerabilities, only
+    /// the blog comments would be affected — but not any Talkyard forum on its own
+    /// sub domain (for organizations with both a forum and blog comments),
+    /// or any user accounts or email addresses.
+    ///
+    /// This could also be configurable, so the site admins could choose to always
+    /// require session part 3, or even require people to interact with the comments
+    /// iframe, trying to make iOS Safari ITP understand that the comments aren't
+    /// a tracker, and allow cookies — then, the server would get the HttpOnly
+    /// cookies. [ios_itp]
+    ///
+    case object EmbeddingStorageSid12 extends MinAuthnStrength(20, fullSidRequired = false)
+
+    /// The embedded sid can be refreshed by popping up a window directly
+    /// against the Talkyard server — then, the session id part 3, in a cookie, would
+    /// be accessible to the javascript in the popup, which could send it to the iframes.
+    /// Or the app server could reply and include parts 1, 2, 3 in the response.
+    /// But only sids part 1+2 (without part 3), would get rejected (since they're
+    /// less safe, see EmbeddingStorageSid12 above). Part 3 would be temporarily
+    /// remembered only in the iframes, never accessible to the embedd*ing* website,
+    //case object EmbeddedIframeSid123 extends MinAuthnStrength(30, part3Required = true)
+
+    //case object EmbeddedIframeSid123Recent extends MinAuthnStrength(35, part3Required = true)
+
+    /// Apart from requiring HttpOnly cookies (session id part 4), what Normal
+    /// authentication is, would be community specific. Some communities might be ok
+    /// with password authn — whilst others might want 2 or 3 factor authn,
+    /// and/or SameSite Strict cookies.
+    case object Normal extends MinAuthnStrength(40)
+
+    /// Like Normal, but must have authenticated recently. Could protect against
+    /// someone grabbing another person's laptop, running away, and then 5 minutes later
+    /// trying to change the other person's email address to hens own — this wouldn't work,
+    /// since hen would need to authenticate again first (so has authenticated just recently).
+    //case object NormalRecent extends MinAuthnStrength(45)
+
+    /// A community can optionally require stronger authentication, for some endpoints
+    /// (or maybe even some specific parts of a community?), or custom groups, mods or admins.
+    /// By default, Strong is the same as Normal — the site admins would need to
+    /// specify somehow what Strong means, in their community's case. Maybe
+    /// OTP + 2FA could be a default? Or should WebAuthn be the default Strong method?
+    /// (Unlike 2FA in general, WebAuthn works against phishing.)
+    //case object Strong extends MinAuthnStrength(50)
+
+    /// Like Strong, but must have authenticated recently — say, within the last X minutes
+    /// (and X is community specific).
+    /// For things like changing one's email address or deleting one's account.
+    //case object StrongRecent extends MinAuthnStrength(55)
+
+    /// Maybe useful if min-auth-strength fn gets called by an internal background job?
+    // case object [InternalJob] extends MinAuthnStrength(99)
+  }
+
+
   // Aliases for better readability.
   type JoinOrLeave = AddOrRemove
   val Join: Add.type = Add
   val StayIfMaySee: KeepIfMaySee.type = KeepIfMaySee
   val Leave: Remove.type = Remove
diff --git a/appsv/server/talkyard/server/http/package.scala b/appsv/server/talkyard/server/http/package.scala
new file mode 100644
index 000000000..02a48b022
--- /dev/null
+++ b/appsv/server/talkyard/server/http/package.scala
@@ -0,0 +1,56 @@
+package talkyard.server
+
+import com.debiki.core._
+import play.api.http.{HeaderNames => play_HeaderNames}
+
+package object http {
+
+  val UserAgentHeaderNormalLength = 100
+
+
+  object HeaderNamesLowercase {
+
+    val UserAgent: St = play_HeaderNames.USER_AGENT.toLowerCase
+
+    val AcceptCH = "accept-ch"
+
+    // ----- User Agent
+
+    // The user agent related headers are:
+    // - User-Agent
+    // - Sec-CH-UA-Mobile    e.g. "?0"
+    // - Sec-CH-UA-Platform  e.g. "Linux"
+    // - Sec-CH-UA     e.g. "Google Chrome";v="95", "Chromium";v="95", ";Not A Brand";v="99"
+
+    // Sec-CH-UA, e.g. "Google Chrome";v="95", "Chromium";v="95", ";Not A Brand";v="99"
+    val ClientHintUserAgent = "sec-ch-ua"
+
+    // Sec-CH-UA-Mobile: "?0" or "?1"
+    val ClientHintUaMobile = "sec-ch-ua-mobile"
+
+    // Sec-CH-UA-Platform: e.g. "Linux"
+    val ClientHintUaPlatform = "sec-ch-ua-platform"
+
+    val ClientHintHeaders: Vec[St] = Vec(
+          ClientHintUserAgent,
+          ClientHintUaMobile,
+          ClientHintUaPlatform)
+
+    val ClientHintHeadersAndUserAgent: Vec[St] =
+          ClientHintHeaders :+ UserAgent
+
+    // ----- Network related
+
+    val SaveData = "save-data"
+
+    // Effective connection type: slow-2g, 2g, 3g, 4g.
+    val Ect = "ect"
+
+    // Approximate round trip time in millis, including application server processing time.
+    val Rtt = "rtt"
+
+    // Approximate bandwidth of the client's connection to the server, in Mbps.
+    val Downlink = "downlink"
+  }
+
+}
diff --git a/appsv/server/talkyard/server/sess/SessionController.scala b/appsv/server/talkyard/server/sess/SessionController.scala
new file mode 100644
index 000000000..d514fd4fc
--- /dev/null
+++ b/appsv/server/talkyard/server/sess/SessionController.scala
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2021 Kaj Magnus Lindberg
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package talkyard.server.sess
+
+import com.debiki.core._
+import debiki.RateLimits
+import debiki.dao.SiteDao
+import debiki.EdHttp._
+import ed.server.{EdContext, EdController}
+import controllers.Utils.OkApiJson
+import play.api.libs.json._
+import javax.inject.Inject
+import play.api.mvc.{Action, ControllerComponents}
+import talkyard.server.JsX._
+import debiki.JsonUtils._
+
+
+class SessionController @Inject()(cc: ControllerComponents, edContext: EdContext)
+  extends EdController(cc, edContext) {
+
+
+  def listSessions(patId: PatId): Action[U] = GetAction { req =>
+    import req.{dao, theRequester => reqer}
+    throwForbiddenIfMayNot("view", dao, reqer, patId)
+    val activeSessions = dao.listPatsSessions(patId)
+    val json = Json.obj("sessions" -> JsArray(activeSessions.map(s => JsSession(s))))
+    OkApiJson(json)
+  }
+
+
+  def terminateSessions: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
+        maxBytes = 2000) { req =>
+    import req.{dao, theRequester => reqer}
+
+    val body = asJsObject(req.body, "The request body")
+    val forPatId = parseInt32(body, "forPatId")
+
+    throwForbiddenIfMayNot("terminate", dao, reqer, forPatId)
+
+    val terminateAllOthers: Bo = parseOptBo(body, "all") getOrElse false
+    val allButNot = if (!terminateAllOthers) None else Some(req.tySession.createdAt)
+
+    val sessionsStartedAtArr: Seq[JsValue] = parseOptJsArray(
+          body, "sessionsStartedAt") getOrElse Nil
+
+    val startTimesMs: Seq[When] = sessionsStartedAtArr map { jsVal =>
+      When.fromMillis(asInt64(jsVal, "Session start date-time"))
+    }
+
+    throwBadReqIf(startTimesMs.nonEmpty && allButNot.isDefined, "TyE4MWJ0202",
+          "Don't specify both 'all' and 'sessionsStartedAt'")
+
+    throwBadReqIf(startTimesMs.isEmpty && allButNot.isEmpty, "TyE4MWJ0203",
+          "Specify one of 'all' and 'sessionsStartedAt'")
+
+    val terminatedSessions = dao.terminateSessions(
+          forPatId, thoseStartedAt = startTimesMs, allButNot)
+
+    val json = Json.obj(
+        "terminatedSessions" -> JsArray(terminatedSessions.map(s => JsSession(s))))
+
+    OkApiJson(json)
+  }
+
+
+  private def throwForbiddenIfMayNot(doWhat: St, dao: SiteDao, reqer: Pat, patId: PatId): U = {
+    throwForbiddenIf(reqer.id != patId && !reqer.isStaff, "TyE0YOURSESS",
+          s"Cannot $doWhat other people's sessions")
+    val pat = dao.getTheParticipant(patId)
+    throwForbiddenIf(pat.isAdmin && !reqer.isAdmin, "TyEADMINSESS_",
+          s"Cannot $doWhat an admin's sessions")
+  }
+
+}
+
diff --git a/appsv/server/talkyard/server/sess/SessionSiteDaoMixin.scala b/appsv/server/talkyard/server/sess/SessionSiteDaoMixin.scala
new file mode 100644
index 000000000..1482eb445
--- /dev/null
+++ b/appsv/server/talkyard/server/sess/SessionSiteDaoMixin.scala
@@ -0,0 +1,165 @@
+/**
+ * Copyright (c) 2021 Kaj Magnus Lindberg
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package talkyard.server.sess
+
+import com.debiki.core._
+import com.debiki.core.Prelude._
+import debiki.EdHttp.urlDecodeCookie
+import debiki.dao.SiteDao
+import talkyard.server.dao.StaleStuff
+
+
+
+trait SessionSiteDaoMixin {
+  self: SiteDao =>
+
+
+  def listPatsSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad] = {
+    val sessionsMaybeBad = readTx(_.loadActiveSessions(patId))
+    SECURITY; COULD // also incl recently ended sessions:
+    //val recentlyActiveSessions = readTx(_.loadRecentlyEndedSessions(patId, limit = 10)) ?
+
+    // Lazy-mark as expired sessions that has just expired.  [lazy_expire_sessions]
+    val settings = getWholeSiteSettings()
+    val now = this.now()
+    val (justExpiredSessions, activeSessions) = sessionsMaybeBad.partition(
+          _.expiresNow(now, expireIdleAfterMins = settings.expireIdleAfterMins))
+    for (session <- justExpiredSessions) {
+      val sessionExpired = session.copy(expiredAt = Some(now))
+      updateSession(sessionExpired)
+    }
+
+    activeSessions
+  }
+
+  def getSessionByPart1(part1Maybe2Or3: St): Opt[TySessionInDbMaybeBad] = {
+    readTx(_.loadSession(part1Maybe2Or3 = Some(part1Maybe2Or3)))
+     // + cache
+  }
+
+
+  /// If maybeActiveOnly, won't load sessions that have been terminated for sure,
+  /// but does load sessions that might have expired, but that has not yet been
+  /// marked as expired in the database.
+  ///
+  def getSessionByPart4HttpOnly(part4: St, maybeActiveOnly: Bo): Opt[TySessionInDbMaybeBad] = {
+    readTx(_.loadSession(part4HttpOnly = Some(part4), maybeActiveOnly = maybeActiveOnly))
+     // + cache
+  }
+
+
+  def insertValidSession(session: TySession): U = {
+    writeTx { (tx, _) =>
+     tx.insertValidSession(session)
+     // + cache
+    }
+  }
+
+
+  def updateSession(session: TySessionInDbMaybeBad): U = {
+    writeTx { (tx, _) =>
+     tx.upsertSession(session)
+
+     // + uncache
+    }
+  }
+
+
+  def terminateSessionForCurReq(req: play.api.mvc.RequestHeader): U = {
+    REFACTOR // Move these cookie names — and their whole file — to this pkg.
+    // Thereafter, can remove this import.
+    import ed.server.security.EdSecurity._
+
+    val sidPart12Maybe3FromHeader: Opt[St] = req.headers.get(SessionIdHeaderName)
+    val sidPart123FromCookie: Opt[St] =
+          urlDecodeCookie(SessionIdPart123CookieName, req)
+
+    val sidPart4FromCookie: Opt[St] =
+          urlDecodeCookie(SessionIdPart4HttpOnlyCookieName, req)
+
+    // Maybe some day some bug can cause the browser to send two different
+    // session part 1 values. Then we'd better delete both sessions.
+    sidPart12Maybe3FromHeader foreach { partsInHeader =>
+      val twoDifferentPart1 = sidPart123FromCookie.exists(!_.startsWith(partsInHeader))
+      if (twoDifferentPart1) {
+        AUDIT_LOG // Is this weird? A script might have has tampered with the cookie
+        // or header?  For now, to catch bugs:
+        warnDevDie("TyE70MWEPG246")
+      }
+      terminateAnySession(
+            sidPart1Maybe2Or3 = Some(partsInHeader),
+            sidPart4 = None)
+    }
+
+    terminateAnySession(
+          sidPart1Maybe2Or3 = sidPart123FromCookie,
+          sidPart4 = sidPart4FromCookie)
+  }
+
+
+  private def terminateAnySession(sidPart1Maybe2Or3: Opt[St], sidPart4: Opt[St]): U = {
+    writeTx { (tx, _) =>
+      val sessions = tx.loadOneOrTwoSessions(sidPart1Maybe2Or3, part4HttpOnly = sidPart4,
+            maybeActiveOnly = true)
+      assert(sessions.forall(_.wasValidJustRecently))  // could remove the filter() below
+      val terminatedSessions =
+            sessions.filter(_.wasValidJustRecently)
+                .map(_.copy(deletedAt = Some(tx.now)))
+      terminatedSessions foreach tx.upsertSession
+
+       // + uncache
+    }
+    AUDIT_LOG // 0, 1 or 2 sessions got terminated.
+  }
+
+
+  /// Returns any sessions that got terminated; isDeleted will be Some(current-time).
+  /// This might include sessions that expired just moments ago — they'll
+  /// be both expiredAt = Some(..) and deletedAt = Some(..).
+  ///
+  def terminateSessions(forPatId: PatId, thoseStartedAt: Seq[When] = Nil,
+          allExceptFor: Opt[When] = None, all: Bo = false,
+          anyTx: Opt[(SiteTx, StaleStuff)] = None)
+          : ImmSeq[TySessionInDbMaybeBad] = {
+
+    dieIf(all && thoseStartedAt.nonEmpty, "TyE60MWEPJ22")
+    dieIf(all && allExceptFor.isDefined, "TyE60MWEPJ23")
+    dieIf(thoseStartedAt.nonEmpty && allExceptFor.isDefined, "TyE60MWEPJ24")
+
+    val sessionsMaybeBad = readTxTryReuse(anyTx.map(_._1))(_.loadActiveSessions(forPatId))
+    val sessionsToEnd = sessionsMaybeBad filter { sess =>
+      warnDevDieIf(!sess.wasValidJustRecently, "TyE5MWJY20X")
+      all || thoseStartedAt.contains(sess.createdAt) ||
+            allExceptFor.isSomethingButNot(sess.createdAt)
+    }
+
+    val now = anyTx.map(_._1.now) getOrElse this.now()
+    val terminatedSessions = sessionsToEnd.map(_.copy(deletedAt = Some(now)))
+
+    if (terminatedSessions.nonEmpty) {
+      writeTxTryReuse(anyTx) { (tx, _) =>
+        terminatedSessions foreach tx.upsertSession
+
+        // + uncache
+      }
+    }
+
+    terminatedSessions
+  }
+
+}
diff --git a/client/app-head/head-bundle.ts b/client/app-head/head-bundle.ts
index 1c7543437..f2cab592d 100644
--- a/client/app-head/head-bundle.ts
+++ b/client/app-head/head-bundle.ts
@@ -287,11 +287,11 @@ if (!eds.isInEmbeddedEditor) {
 // Backw compat CLEAN_UP convert old js code in these 'namespaces' to Typescript instead [4KSWPY]
 // RENAME to tyd ("Talkyard Dynamic" things, like is-sth-ready promises?, remove 'internal' and 'v0'
 var debiki = { internal: {}, v0: { util: {}} };
 
 // Talkyard per page load session data (if we avoid cookies). [NOCOOKIES]   rename to  tyd?  see above *@
-// Maybe store this in Myself instead? Next to Myself.mySidPart1ForJs? [sess_in_me]
+// Maybe store this in Me instead? Next to Me.mySidPart1? [sess_in_me]
 // And remove typs.
 var typs: PageSession = {
   xsrfTokenIfNoCookies: _volatileData.xsrfTokenIfNoCookies,
   canUseCookies: navigator.cookieEnabled
 };
diff --git a/client/app-more/login/login-dialog.more.ts b/client/app-more/login/login-dialog.more.ts
index cc6f1e338..c148a492f 100644
--- a/client/app-more/login/login-dialog.more.ts
+++ b/client/app-more/login/login-dialog.more.ts
@@ -165,11 +165,11 @@ const LoginDialog = createClassAndFactory({
         loginReason,
         afterLoginCallback: callback,
         anyReturnToUrl,
         preventClose: preventClose || loginReason === LoginReason.AuthnRequiredToRead ||
             loginReason === LoginReason.LoginToAdministrate,
-        isLoggedIn: !!getSetCookie('dwCoSid'),
+        isLoggedIn: store.me.isLoggedIn,
       });
   },
 
 
   /// Returns: [anyAfterLoginCallback, anyReturnToUrl]
@@ -244,10 +244,18 @@ const LoginDialog = createClassAndFactory({
       loginReason: null,
       afterLoginCallback: null,
       anyReturnToUrl: null,
       isLoggedIn: null,
     });
+    /* COULD_OPTIMIZE: Clear these cookies, maybe all authn cookies? [clear_aun_cookies]
+     * Clear cookies both server side — and from here too, in case closing the dialog
+     * without logging in?
+     * (This close() runs also after the create-user dialog, via:
+     * closeDialog('CloseAllLoginDialogs'))
+    getSetCookie('dwCoIsInLoginWindow', null);
+    getSetCookie('TyCoAvoidCookies', null);
+     */
   },
 
   setChildDialog: function(childDialog) {
     this.setState({ childDialog: childDialog });
   },
@@ -551,11 +559,11 @@ const LoginDialogContent = createClassAndFactory({
     //logD(`location.toString() was: ${location.toString()}`);
 
     if (anySsoUrl) {
       // Maybe incl username and id in __html_encoded_volatile_json__ ?
       // Not always done in login window.
-      const hasSid = getSetCookie('dwCoSid');
+      const hasSid = me_hasSid();
       const loggedInButMayNotAccess = !hasSid ? null : r.p({},
         "You're logged in but seems you cannot access this part of the site " +  // I18N
         "(if it exists). " +
         "Can you login as a user with higher permissions?")
       content =
diff --git a/client/app-more/topbar/my-menu.more.ts b/client/app-more/topbar/my-menu.more.ts
index 16876964e..be35fffd0 100644
--- a/client/app-more/topbar/my-menu.more.ts
+++ b/client/app-more/topbar/my-menu.more.ts
@@ -39,10 +39,11 @@ export const MyMenuContent = createFactory({
     // so better ask, in case clicked by accident.
     util.openDefaultStupidDialog({
       dialogClassName: 'e_ByeD',
       tiny: true,
       body: t.LogOut + '?',
+      // + "[x] Log out from all devices"  — if there're other sessions as well
       primaryButtonTitle: t.YesBye,
       secondaryButonTitle: t.Cancel,
       onCloseOk: function(whichButton) {
         if (whichButton === 1) {
           debiki2.ReactActions.logout();
diff --git a/client/app-more/users/user-preferences.more.ts b/client/app-more/users/user-preferences.more.ts
index a286cac23..e0a85a8ab 100644
--- a/client/app-more/users/user-preferences.more.ts
+++ b/client/app-more/users/user-preferences.more.ts
@@ -30,10 +30,11 @@ const SlashPrefsSlash = '/preferences/';
 
 import EmailInput = debiki2.util.EmailInput;
 const aboutPathSeg = 'about';
 const notfsPathSeg = 'notifications';
 const privacyPathSeg = 'privacy';
+const securityPathSeg = 'security';
 const uiPathSeg = 'ui';
 const accountPathSeg = 'account';  // [4JKT28TS]
 
 
 export const UserPreferences = createFactory({
@@ -42,10 +43,11 @@ export const UserPreferences = createFactory({
   render: function() {
     const user: UserInclDetails = this.props.user;
     const prefsPathSlash = pathTo(user) + SlashPrefsSlash;
     const aboutPath = prefsPathSlash + aboutPathSeg;
     const privacyPath = prefsPathSlash + privacyPathSeg;
+    const securityPath = prefsPathSlash + securityPathSeg;
     const uiPath = prefsPathSlash + uiPathSeg;
     const emailsLoginsPath = prefsPathSlash + accountPathSeg;
     const location = this.props.location;
     const store: Store = this.props.store;
     const me: Myself = store.me;
@@ -68,10 +70,11 @@ export const UserPreferences = createFactory({
       Route({ path: prefsPathSlash, exact: true, render: () =>
           Redirect({ to: aboutPath + location.search + location.hash })}),
       Route({ path: '(.*)/' + aboutPathSeg, exact: true, render: () => AboutTab(childProps) }),
       Route({ path: '(.*)/' + notfsPathSeg, exact: true, render: () => NotfPrefsTab(childProps) }),
       Route({ path: '(.*)/' + privacyPathSeg, exact: true, render: () => PrivacyPrefsTab(childProps) }),
+      Route({ path: '(.*)/' + securityPathSeg, exact: true, render: () => SecurityPrefsTab(childProps) }),
       Route({ path: '(.*)/' + accountPathSeg, exact: true, render: (ps) =>
           user.isGroup
             ? AccountTabForGroup({ ...childProps, ...ps })
             : AccountTab({ ...childProps, ...ps }) }),
       Route({ path: '(.*)/' + uiPathSeg, exact: true, render: () => UiPrefsTab(childProps) }),
@@ -93,10 +96,12 @@ export const UserPreferences = createFactory({
               LiNavLink({ to: aboutPath, className: 's_UP_Prf_Nav_AbtL' }, t.upp.About),
               !isNormalMember ? null: LiNavLink({
                   to: prefsPathSlash + notfsPathSeg, className: 's_UP_Prf_Nav_NtfsL' }, t.Notifications),
               isGroupGuestOrBuiltIn ? null : LiNavLink({
                   to: privacyPath, className: 'e_UP_Prf_Nav_PrivL' }, t.upp.Privacy),
+              isGroupGuestOrBuiltIn ? null : LiNavLink({
+                  to: securityPath, className: 'e_UP_Prf_Nav_SecL' }, t.upp.Security),
               isGuestOrBuiltIn ? null : LiNavLink({
                   to: emailsLoginsPath, className: 's_UP_Prf_Nav_EmLgL' }, t.upp.Account),
               !isNormalMember ? null : LiNavLink({
                   to: uiPath, className: 'e_UP_Prf_Nav_UiL' }, t.upp.Interface))),
          r.div({ className: 's_UP_Act_List' },
@@ -783,10 +788,131 @@ const PrivacyPrefsTab = createFactory({
         savingInfo));
   }
 });
 
 
+
+const SecurityPrefsTab = React.createFactory<any>(function(props: {
+        user: UserInclDetails, store: Store }) {
+
+  const store = props.store;
+  const user = props.user;
+
+  const [sessionsOrNull, setSessions] = React.useState<Session[] | N>(null);
+
+  // Break out hook? [my_cur_id]
+  const me = store.me;
+  const myIdRef = React.useRef(me.id);
+
+  React.useEffect(() => {
+    myIdRef.current = me.id;
+    listPatsSessions();
+    return () => myIdRef.current = null;
+  }, []);//, [me.id, user.id]);
+
+  function listPatsSessions() {
+    // If one is admin, then, `me` can be !== `user`.
+    Server.listSessions(user.id, (resp: ListSessionsResponse) => {
+      if (myIdRef.current !== me.id) return;
+      // Show most recent first.
+      const sessionsByTime = [...resp.sessions].sort(
+              function(a,b ) { return b.createdAt - a.createdAt; });
+      setSessions(sessionsByTime);
+    });
+  }
+
+  if (!sessionsOrNull)
+    return r.p({}, t.Loading);
+
+  const sessions: Session[] = sessionsOrNull;
+  let numActive = 0;
+
+  const sessionItems = sessions.map((session: Session) => {
+    if (!session.deletedAt && !session.expiredAt) numActive += 1;
+    return r.li({ key: session.createdAt },
+        SessionInfo(session, endSession, me));
+  });
+
+  function endSession(ps: { session?: Session, all?: true }) {
+    const sessionsStartedAt = ps.session && [ps.session.createdAt];
+    Server.terminateSessions({ forPatId: user.id, sessionsStartedAt, all: ps.all },
+            (resp: TerminateSessionsResponse) => {
+      if (myIdRef.current !== me.id) return;
+      // Is sorted by time already, see sort(..) above.
+      const sessionsAfter = arr_replaceMany(sessions,
+              resp.terminatedSessions, (s: Session) => s.createdAt);
+      setSessions(sessionsAfter);
+    });
+  }
+
+  const isMyOnlySession = me.id === user.id && numActive === 1;
+
+  const logOutEverywhereBtn = !numActive || isMyOnlySession ? null :
+      Button({ className: 'c_SessL_EndAllB', onClick: () => endSession({ all: true })},
+          "Log out everywhere (but not here)");   // I18N
+
+  return (
+    r.div({},
+      // Later: "You're logged in on these devices:"
+      // Or: "You are logged in on these devices, or were recently:"
+      // Or: "Active or recently active devices:"  ?
+      r.h2({}, "Active sessions:"),  // I18N
+      r.ol({ className: 'c_SessL' }, sessionItems),
+      sessionItems.length ? null : r.p({}, "None"), // I18N
+      logOutEverywhereBtn,
+      ));
+});
+
+
+function SessionInfo(session: Session, endSessionFn: (ps: { session: Session }) => V, me: Me) {
+  const createdAt: St = new Date(session.createdAt).toISOString();
+  let activeOrEnded: St;
+
+  let deletedAt = session.deletedAt;
+  let expiredAt = session.expiredAt;
+  if (deletedAt && expiredAt) {
+    if (deletedAt <= expiredAt) expiredAt = null;
+    else deletedAt = null;
+  }
+
+  const isCurrent = me.mySidPart1 !== session.part1 ? '' :
+          " — this session, here";
+
+  let terminateBtn: RElm | U;
+  let activeOrEndedClass = 'c_SessL_Sess-Ended';
+
+  if (deletedAt) {
+    activeOrEnded = " — got deleted at " + new Date(deletedAt).toISOString();
+  }
+  else if (expiredAt) {
+    activeOrEnded = " — expired at " + new Date(expiredAt).toISOString();
+  }
+  else {
+    activeOrEnded = isCurrent ? '' : " — currently active";
+    activeOrEndedClass = 'c_SessL_Sess-Active';
+
+    // Skip logout button for the current session. It's better if one stays logged
+    // in and can see that the relevant sessions got terminated properly. And
+    // thereafter click Log Out in one's username menu. (Or?)
+    terminateBtn = me.mySidPart1 === session.part1 ? null :
+          Button({ className: 'c_SessL_Sess_EndB',
+              onClick: () => endSessionFn({ session }) }, "Log out");  // I18N
+  }
+
+  let debugJson = null;
+  // @ifdef DEBUG
+  debugJson = r.pre({}, JSON.stringify(session, undefined, 3));
+  // @endif
+
+  return r.div({ className: 'c_SessL_Sess ' + activeOrEndedClass },
+      r.span({}, "Session started at " + createdAt + isCurrent + activeOrEnded),
+      debugJson,
+      terminateBtn);
+}
+
+
+
 const AccountTabForGroup = React.createFactory<any>(function(props: { member: Group, store: Store }) {
   const me: Myself = props.store.me;
   const group: Group = props.member;
 
   function deleteGroup() {
diff --git a/client/app-slim/ReactActions.ts b/client/app-slim/ReactActions.ts
index 69ed5e0b5..11843597c 100644
--- a/client/app-slim/ReactActions.ts
+++ b/client/app-slim/ReactActions.ts
@@ -79,12 +79,17 @@ export function loadMyself(afterwardsCallback?: () => Vo) {
   // deleted by the server.)
 
   Server.loadMyself((anyMe: Me | NU, stuffForMe?: StuffForMe) => {
     // @ifdef DEBUG
     // Might happen if there was no weakSessionId, and also, no cookie.
-    dieIf(!anyMe, 'TyE4032SMH57');
+    // Or if our session just got terminated from another device?
+    // To try to make this happen, see /^sessions/ in tests-map.txt.
+    dieIf(!anyMe, 'Server.loadMyself() returned me = null [TyE4032SMH57]');
     // @endif
+    // Maybe?: if (!anyMe) return;
+    // Or?: if (!anyMe) logoutClientSideOnly()
+
     const newMe = anyMe as Me;
     if (isInSomeEmbCommentsIframe()) {
       // Tell the embedded comments or embedded editor iframe that we just logged in,
       // also include the session id, so Talkyard's script on the embedding page
       // can remember it — because cookies and localstorage in an iframe typically
@@ -138,17 +143,19 @@ export function newUserAccountCreated() {
 //------------------------------------------------------
 // REFACTOR  Move these to an Authn module?  [ts_authn_modl]
 // Together with Server.deleteTempSessId() and Server.rememberTempSession().
 
 
+/// Not currently in use? But maybe call this one instead of
+/// Server.logoutServerAndClientSide() drectly?
 export function logout() {
   Server.logoutServerAndClientSide();
 }
 
 
 export function logoutClientSideOnly(ps: { goTo?: St, skipSend?: Bo } = {}) {
-  Server.deleteTempSessId();
+  Server.deleteTempSessId();  // [is_logging_out]
 
   ReactDispatcher.handleViewAction({
     actionType: actionTypes.Logout
   });
 
diff --git a/client/app-slim/Server.ts b/client/app-slim/Server.ts
index e0d9acbc1..6f11c5626 100644
--- a/client/app-slim/Server.ts
+++ b/client/app-slim/Server.ts
@@ -1151,10 +1151,31 @@ export function loginWithOneTimeSecret(oneTimeLoginSecret: string,
   get(`/-/v0/login-with-secret?oneTimeSecret=${oneTimeLoginSecret}`,
       makeUpdNoCookiesTempSessionIdFn(onDone));
 }
 
 
+/// Returns parts 1 and 2 of any current session id, maybe 3. (Parts 4 and 5 are HttpOnly
+/// cookies, not accessible here.)
+///
+export function getCurSid12Maybe3(): St | N {  // [ts_authn_modl]
+  const store: Store = debiki2.ReactStore.allData();
+  const cookieName =
+          debiki2.store_isFeatFlagOn(store, 'ffUseNewSid') ? 'TyCoSid123' : 'dwCoSid';
+  let sid = getSetCookie(cookieName);
+  if (!sid) {
+    // Cannot use store.me.mySidPart1 — we might not yet have loaded
+    // the current user from the server; store.me might be stale.
+    const typs: PageSession = getMainWin().typs;
+    // This might not include part 3 (won't, if we're in an embedded comments
+    // iframe, and didn't login or resume via a popup win directly against the
+    // server so we could access cookie TyCoSid123, which includes part 3).
+    sid = typs.weakSessionId;
+  }
+  return sid || null;
+}
+
+
 export function rememberTempSession(ps: { weakSessionId: St }) {  // [ts_authn_modl]
   const onOk = function() {};
   makeUpdNoCookiesTempSessionIdFn(onOk)(ps);
 }
 
@@ -1179,17 +1200,19 @@ function makeUpdNoCookiesTempSessionIdFn<R>(  // [ts_authn_modl]
   };
 }
 
 
 export function deleteTempSessId() {  // [ts_authn_modl]
+  // Need not delete store.me.mySidPart1 — we'll reload the page anyway. [is_logging_out]
   const mainWin = getMainWin();
   const typs: PageSession = mainWin.typs;
   delete typs.weakSessionId;
   delete typs.sessType;
   try {
     // Can this throw?
     getSetCookie('dwCoSid', null);
+    getSetCookie('TyCoSid123', null);
   }
   catch (ex) {
     // Just in case.
     logW(`TyE603MSEL47`);
   }
@@ -1281,14 +1304,13 @@ export function listCompleteUsers(whichUsers, success: (users: UserInclDetailsWi
 
 
 type UserAcctRespHandler = (response: UserAccountResponse) => void;
 
 
-export function loadEmailAddressesAndLoginMethods(userId: UserId, success: UserAcctRespHandler) {
-  get(`/-/load-email-addrs-login-methods?userId=${userId}`, response => {
-    success(response);
-  });
+export function loadEmailAddressesAndLoginMethods(userId: UserId, onOk: UserAcctRespHandler,
+          onErr?: (resp: A) => V) {
+  get(`/-/load-email-addrs-login-methods?userId=${userId}`, onOk, onErr);
 }
 
 
 export function resendOwnerEmailAddrVerifEmail(success) {
   postJsonSuccess('/-/resend-owner-email-addr-verif-email', success, {});
@@ -1301,12 +1323,13 @@ export function resendEmailAddrVerifEmail(userId: UserId, emailAddress: string)
   postJsonSuccess('/-/resend-email-addr-verif-email', (response: UserAccountResponse) => {
     morebundle.openDefaultStupidDialog({ body: "Email sent" });
   }, { userId, emailAddress });
 }
 
-export function addEmailAddresses(userId: UserId, emailAddress: string, success: UserAcctRespHandler) {
-  postJsonSuccess('/-/add-email-address', success, { userId, emailAddress });
+export function addEmailAddresses(userId: UserId, emailAddress: St, onOk: UserAcctRespHandler,
+        onErr: (resp: A) => V) {
+  postJsonSuccess('/-/add-email-address', onOk, onErr, { userId, emailAddress });
 }
 
 
 export function removeEmailAddresses(userId: UserId, emailAddress: string,
       success: UserAcctRespHandler) {
@@ -1348,12 +1371,13 @@ export function loadAllInvites(success: (invites: Invite[]) => void) {
 export function editMember(userId: UserId, doWhat: EditMemberAction, success: () => void) {
   postJsonSuccess('/-/edit-member', success, { userId: userId, doWhat: doWhat });
 }
 
 
-export function suspendUser(userId: UserId, numDays: number, reason: string, success: () => void) {
-  postJsonSuccess('/-/suspend-user', success, {
+export function suspendUser(userId: UserId, numDays: Nr, reason: St, onOk: () => V,
+        onErr: (resp: A) => V) {
+  postJsonSuccess('/-/suspend-user', onOk, onErr, {
     userId: userId,
     numDays: numDays,
     reason: reason
   });
 }
@@ -1489,10 +1513,24 @@ export function loadMyself(onOk: (me: Me | NU, stuffForMe?: StuffForMe) => Vo) {
   // SHOULD incl sort order & topic filter in the url params. [2KBLJ80]
   get(`/-/load-my-page-data?pageIds=${pageIds}`,
         function (resp: { me?: Me, stuffForMe?: StuffForMe }) {
     onOk(resp.me, resp.stuffForMe);
   });
+    // onErr(() => send 'failedToLogin' to parent frame)  [forget_sid12]
+}
+
+
+export function listSessions(patId: PatId, onOk: (resp: ListSessionsResponse) => V,
+        onErr: () => V) {
+  get(`/-/list-sessions?patId=${patId}`, onOk, onErr);
+}
+
+
+export function terminateSessions(
+        ps: { forPatId: PatId, sessionsStartedAt?: WhenMs[], all?: true },
+        onOk: (response: TerminateSessionsResponse) => V, onErr: () => V) {
+  postJsonSuccess(`/-/terminate-sessions`, onOk, ps, onErr);
 }
 
 
 export function listDrafts(userId: UserId,
       onOk: (response: ListDraftsResponse) => void, onError: () => void) {
diff --git a/client/app-slim/if-in-iframe.ts b/client/app-slim/if-in-iframe.ts
index 6f0272319..27f469f26 100644
--- a/client/app-slim/if-in-iframe.ts
+++ b/client/app-slim/if-in-iframe.ts
@@ -109,10 +109,11 @@ function onMessage(event) {
         // REFACTOR break out fn Authn.loginWithOldSession()?  [ts_authn_modl]
         const mainWin = debiki2.getMainWin();
         mainWin.typs.weakSessionId = eventData.weakSessionId;
         typs.weakSessionId = eventData.weakSessionId;
         // This sends 'justLoggedIn' to other iframes, so they'll get updated too.
+        // Reply 'failedToLogin' if login failed  [forget_sid12]
         ReactActions.loadMyself();
       }
       else {
         // This session id won't work — it's for some other Talkyard site.
         // This happens if debugging and testing on localhost, deleting and
@@ -122,13 +123,13 @@ function onMessage(event) {
     case 'justLoggedIn':
       // The getMainWin().typs.weakSessionId has been updated already, by
       // makeUpdNoCookiesTempSessionIdFn() or in the 'case:' just above, lets check:
       // @ifdef DEBUG
       const mainWin: MainWin = getMainWin();
-      if (!mainWin.typs.weakSessionId && !getSetCookie('dwCoSid')) {
+      if (!me_hasSid()) {
         logAndDebugDie(`justLoggedIn but not logged in? ` +
-            `No cookie, no typs.weakSessionId. ` +
+            `No session cookie, no typs.weakSessionId. ` +
             `This frame name: ${window.name}, ` +
             `main frame name: ${mainWin.name}, ` +
             `this is main frame: ${window === mainWin}, ` +
             `mainWin.typs: ${JSON.stringify(mainWin.typs)} [TyE60UKTTGL35]`);
       }
@@ -140,10 +141,13 @@ function onMessage(event) {
       // Could assert present also in setNewMe() and loadMyself(), and change
       // param to cannot-be-undefined.
       // @endif
       ReactActions.setNewMe(eventData.user, eventData.stuffForMe);
       break;
+    case 'logoutServerAndClientSide':
+      Server.logoutServerAndClientSide();
+      break;
     case 'logoutClientSideOnly':
       // Sent from the comments iframe one logged out in, to the editor iframe
       // and other comments iframes.
       ReactActions.logoutClientSideOnly({ skipSend: true });
       break;
diff --git a/client/app-slim/login/login-if-needed.ts b/client/app-slim/login/login-if-needed.ts
index 9f7eb629b..7523654f6 100644
--- a/client/app-slim/login/login-if-needed.ts
+++ b/client/app-slim/login/login-if-needed.ts
@@ -286,10 +286,11 @@ export function continueAfterLogin(anyReturnToUrl?: string) {
         pagedialogs.getServerErrorDialog().openForBrowserError(
           "You closed the main browser window, which we were going to " +
             "continue in?  [TyEOPNRGONE]", { mayClose: false });
       }
       else {
+        // We've remembered any weakSessionId already, see [5028KTDN306].
         window.opener['debiki'].internal.handleLoginResponse({ status: 'LoginOk' });
         // Close this popup window — we'll continue in the main window.
         close();
       }
     }
diff --git a/client/app-slim/me-getters.ts b/client/app-slim/me-getters.ts
index 37a58b543..6d15ce80a 100644
--- a/client/app-slim/me-getters.ts
+++ b/client/app-slim/me-getters.ts
@@ -28,10 +28,20 @@
 //------------------------------------------------------------------------------
    module debiki2 {
 //------------------------------------------------------------------------------
 
 
+/// I can haz session id?
+///
+export function me_hasSid(): Bo {
+  return !!(
+        getSetCookie('dwCoSid') ||        // old
+        getSetCookie('TyCoSid123') ||     // new, better  [btr_sid]
+        getMainWin().typs.weakSessionId); // if cookies don't work
+}
+
+
 export function me_isAuthenticated(me: Myself): boolean {
   return me.id && me.id >= MinMemberId;
 }
 
 
diff --git a/client/app-slim/model.ts b/client/app-slim/model.ts
index 0a8489d96..94fd39849 100644
--- a/client/app-slim/model.ts
+++ b/client/app-slim/model.ts
@@ -33,11 +33,11 @@ interface PageSession  {
   // It's needed because Safari and FF blocks 3rd party cookies, so
   // we need to remember the login session in a non-cookie somehow.
   //
   // ADD_TO_DOCS
   //
-  weakSessionId?: St;
+  weakSessionId?: St;  // RENAME to sid12Maybe3 ?
 
   // If the session is for an embedded comments iframe. REMOVE incl in sid instead, somehow.
   sessType?: SessionType.AutoTokenSiteCustomSso;
 }
 
@@ -71,10 +71,14 @@ interface CheckboxEvent {
   target: {
     checked: boolean;
   };
 }
 
+/// Either compares two items (if function.length === 2) or compares
+/// a field value (if length === 1).
+type ArrItemIsSameFn<Item> = ((a: Item, b: Item) => Bo) | ((it: Item) => any);
+
 type ValueOk<T> = {
   value?: T;
   isOk?: boolean;
 };
 
@@ -478,10 +482,13 @@ interface OwnPageNotfPrefs {  // RENAME to MembersPageNotfPrefs?
 
 // Extend Pat, set id to a new StrangerId if not logged in?
 type Myself = Me; // renaming to Me
 interface Me extends OwnPageNotfPrefs {   // + extends Pat?
   dbgSrc?: string;
+  // This is not the whole session id — it's the first 16 chars only [sid_part1];
+  // the remaining parts have (a lot) more entropy than necessary.
+  mySidPart1?: St | N;
   id?: UserId;
   isStranger?: Bo;
   // missing?: isGuest?: Bo
   isGroup?: boolean; // currently always undefined (i.e. false)
   isLoggedIn?: boolean;
@@ -563,10 +570,21 @@ type MePatch = MyselfPatch;  // renaming all 'Myself' to 'Me'
 interface StuffForMe {
   tagTypes?: TagType[];
 }
 
 
+interface Session {
+  patId: PatId;
+  createdAt: WhenMs;
+  version: Nr,
+  startHeaders: { [name: St]: St };
+  part1: St;
+  deletedAt?: WhenMs;
+  expiredAt?: WhenMs;
+}
+
+
 interface GroupPerms {
   maxUploadBytes?: Nr;
   allowedUplExts?: St;
 }
 
@@ -2396,10 +2414,20 @@ interface LoadPatVvbResponse {
   groupsMaySee: Group[];
   tagTypes: TagType[];
 }
 
 
+interface ListSessionsResponse {
+  sessions: Session[];
+}
+
+
+interface TerminateSessionsResponse {
+  terminatedSessions: Session[];
+}
+
+
 // COULD also load info about whether the user may apply and approve the edits.
 interface LoadDraftAndTextResponse {
   pageId: PageId;
   postNr: PostNr;
   postUid: PostId; // CLEAN_UP RENAME to just postId.
diff --git a/client/app-slim/oop-methods.ts b/client/app-slim/oop-methods.ts
index c0cf7d6dc..693d2d0ba 100644
--- a/client/app-slim/oop-methods.ts
+++ b/client/app-slim/oop-methods.ts
@@ -748,12 +748,16 @@ export function settings_selectTopicType(settings: SettingsVisibleClientSide, me
 // Site
 //----------------------------------
 
 
 export function store_isFeatFlagOn(store: Store, featureFlag: St): Bo {
-  return _.includes(store.siteFeatureFlags, featureFlag) ||
+  const offFlag = '0' + featureFlag;
+  const isOn = _.includes(store.siteFeatureFlags, featureFlag) ||
          _.includes(store.serverFeatureFlags, featureFlag);
+  const isOff = _.includes(store.siteFeatureFlags, offFlag) ||
+         _.includes(store.serverFeatureFlags, offFlag);
+  return isOn && !isOff;   // [ff_on_off]
 }
 
 
 
 // Store
diff --git a/client/app-slim/prelude.ts b/client/app-slim/prelude.ts
index 27426a8db..bb897851e 100644
--- a/client/app-slim/prelude.ts
+++ b/client/app-slim/prelude.ts
@@ -41,11 +41,11 @@ declare function $$(selector: string): Element[];
 // Defined in client/third-party/smoothscroll-tiny.js.
 declare function smoothScroll(elem: Element, x: number, y: number,
     durationMs?: number, onDone?: () => void);
 
 // Defined in client/third-party/get-set-cookie.js.
-declare function getSetCookie(cookieName: string, value?: string, options?: any): string;
+declare function getSetCookie(cookieName: St, value?: St, options?: Ay): St | Nl;
 
 
 // backw compat, later, do once per file instead (don't want a global 'r').
 //
 // ReactDOMFactories looks like:
@@ -515,20 +515,60 @@ export function firstDefinedOf(x, y, z?) {
   return !_.isUndefined(x) ? x : (!_.isUndefined(y) ? y : z);
 }
 
 
 /** Like _.groupBy but keeps just one value per key.
+    RENAME to arr_groupByKeepOne ?
   */
 export function groupByKeepOne<V>(vs: V[], fn: (v: V) => number): { [key: number]: V } {
   const manyById: { [key: number]: V[] } = _.groupBy(vs, fn);
   const oneById:  { [key: number]: V   } = _.mapValues(manyById, many => many[0]);
   return oneById;
 }
 
 
+export function arr_replaceMany<Item>(
+        arr: Item[], newerItems: Item[], isSame: ArrItemIsSameFn<Item>): Item[] {
+  const newArr = [...arr];
+  arr_replaceManyInPl(newArr, newerItems, isSame);
+  return newArr;
+}
+
+
+export function arr_replaceManyInPl<Item>(
+        arr: Item[], newerItems: Item[], isSame: ArrItemIsSameFn<Item>) {
+  for (const newerItem of newerItems) {
+    arr_replaceOneInPl(arr, newerItem, isSame);
+  }
+}
+
+
+export function arr_replaceOneInPl<Item>(
+        arr: Item[], newerItem: Item, isSame: ArrItemIsSameFn<Item>) {
+  const fnLen = isSame.length;
+  // @ifdef DEBUG
+  dieIf(fnLen < 1 || 2 < fnLen, 'TyE60MEPWH32');
+  // @endif
+
+  const getFieldVal = fnLen === 1 ? isSame as ((item: Item) => A) : undefined;
+  const newerFieldVal = getFieldVal && getFieldVal(newerItem);
+
+  const ix = _.findIndex(arr, function(oldItem) {
+    if (getFieldVal) return getFieldVal(oldItem) === newerFieldVal;
+    else return isSame(oldItem, newerItem);
+  });
+
+  if (ix >= 0) {
+    arr[ix] = newerItem;
+  }
+}
+
+
 // Finds and replaces (in-place) the first item with item.id = replacement.id.
 // Dies, if there's not matching item.
+// REFACTOR use arr_replaceOneInPl & x => x.id  instead.
+// + add an IfNotFound.ThenDie/ThenAppend/ThenIgnore param? Default to ThenDie?
 export function replaceById(itemsWithId: any[], replacement) {
   // @ifdef DEBUG
   dieIf(isNullOrUndefined(replacement.id), 'EdE4GJTH02');
   // @endif
 
diff --git a/client/app-slim/slim-bundle.d.ts b/client/app-slim/slim-bundle.d.ts
index 5daef9fb7..549c34cf6 100644
--- a/client/app-slim/slim-bundle.d.ts
+++ b/client/app-slim/slim-bundle.d.ts
@@ -187,10 +187,18 @@ declare namespace debiki2 {
 
   var createComponent: any;       // don't use — I'm renaming to createFactory
   var createClassAndFactory: any; // don't use — I'm renaming to createFactory
   function createFactory<P, S = any>(compSpec: React.ComponentSpec<P, S>): React.Factory<any>;
 
+  function arr_replaceMany<Item>(arr: Item[],
+        newerItems: Item[], isSame: ArrItemIsSameFn<Item>): Item[];
+
+  function arr_replaceManyInPl<Item>(arr: Item[],
+        newerItems: Item[], isSame: ArrItemIsSameFn<Item>);
+
+  function arr_replaceOneInPl<Item>(arr: Item[],
+        newerItem: Item, isSame: ArrItemIsSameFn<Item>);
 
   function replaceById(itemsWithId: any[], replacement);
   function deleteById(itemsWithId: any[], id);
   function url_getHost(url: St): St;
   function arr_sortAlphaInPlace<V>(vs: V[], strFn: (v: V) => St);
@@ -312,10 +320,11 @@ declare namespace debiki2 {
   function event_isEscape(event): boolean;
   function page_isChat(pageRole: PageRole): boolean;
   function page_isPrivateGroup(pageRole: PageRole): boolean;
   function pageRole_iconClass(pageRole: PageRole): string;
 
+  function me_hasSid(): Bo;
   function me_uiPrefs(me: Myself): UiPrefs;
   function pp_snoozeLeftMs(me: Myself): number;
   function member_isBuiltIn(member: Member): boolean;
   function user_isSuspended(user: UserInclDetails, nowMs: WhenMs): boolean;
   function user_threatLevel(user: UserInclDetails): ThreatLevel;
diff --git a/client/app-slim/start-page.ts b/client/app-slim/start-page.ts
index b6f9f67ec..80b9ea240 100644
--- a/client/app-slim/start-page.ts
+++ b/client/app-slim/start-page.ts
@@ -16,17 +16,19 @@
  */
 
 /// <reference path="prelude.ts" />
 /// <reference path="init-all-react-roots.ts" />
 /// <reference path="link-previews.ts" />
+/// <reference path="oop-methods.ts" />
 
 
 const d = { i: debiki.internal };
 
 const logM = debiki2.logM;
 const logE = debiki2.logE;
 
+
 let pageStarted: undefined | true;
 const scriptLoadDoneCallbacks = [];
 debiki.scriptLoad = {  // RENAME to tyd.afterStarted(...) ?
   done: function(callback) {
     if (pageStarted) callback();
@@ -60,20 +62,54 @@ debiki.nowServiceWorkerIsRightVersion = function() {
 
 
 const allPostsNotTitleSelector = '.debiki .dw-p:not(.dw-p-ttl)';
 
 
+
 function handleLoginInOtherBrowserTab() {
-  const currentUser = debiki2.ReactStore.getMe();
+  const store: Store = debiki2.ReactStore.allData();
+  const me: Myself = store.me;
+
+  // New style session id:  [btr_sid]
+  // ------------------------------------------
+  if (debiki2.store_isFeatFlagOn(store, 'ffUseNewSid')) {
+    const sidParts123 = debiki2.Server.getCurSid12Maybe3();
+    const stillTheSameSid = sidParts123
+          ? me.mySidPart1 && sidParts123.startsWith(me.mySidPart1)
+          : !me.mySidPart1;
+    if (!stillTheSameSid) {
+      if (sidParts123) {
+        // The human has logged in.
+        if (me.mySidPart1) {
+          // The human was already logged in. Then it's good to reload the page so
+          // stuff related to the previous user, disappears from the browser memory.
+          location.reload();
+        }
+        else {
+          // The human wasn't logged in. We can load the human's data without page reload.
+          debiki2.ReactActions.loadMyself();
+        }
+      }
+      else {
+        // The human was logged in, but has now logged out. (This reloads the
+        // page, or loads a new page, and forgets all in-mem state.)
+        debiki2.ReactActions.logoutClientSideOnly();
+      }
+    }
+    return;
+  }
+
+  // Old style session id:
+  // ------------------------------------------
   const sessionId = getSetCookie('dwCoSid');
-  if (currentUser.isLoggedIn) {
+  if (me.isLoggedIn) {
     if (sessionId) {
       // Session id example: (parts: hash, user id, name, login time, random value)
       // 'Y1pBlH7vY4JW9A.11.Magnus.1316266102779.15gl0p4xf7'
       const parts = sessionId.split('.');
       const newUserIdString = parts[1];
-      if (currentUser.userId !== parseInt(newUserIdString)) {
+      if (me.id !== parseInt(newUserIdString)) {
         // We've logged in as another user in another browser tab.
         debiki2.ReactActions.loadMyself();
       }
     }
     else {
@@ -92,10 +128,11 @@ function handleLoginInOtherBrowserTab() {
     debiki2.ReactActions.loadMyself();
   }
 }
 
 
+
 function registerEventHandlersFireLoginOut() {
   // If the user switches browser tab, s/he might logout and login
   // in another tab. That'd invalidate all xsrf tokens on this page,
   // and user specific permissions and ratings info (for this tab).
   // Therefore, when the user switches back to this tab, check
diff --git a/client/app-slim/translations.d.ts b/client/app-slim/translations.d.ts
index 1fcfc416c..587c69eda 100644
--- a/client/app-slim/translations.d.ts
+++ b/client/app-slim/translations.d.ts
@@ -666,10 +666,11 @@ interface TalkyardTranslations {
     Preferences: string;
     Invites: string;
     DraftsEtc: string;
     About: string;
     Privacy: string;
+    Security: string;
     Account: string;
     Interface: string;
 
     // ----- Overview stats
 
diff --git a/client/embedded-comments/blog-comments.ts b/client/embedded-comments/blog-comments.ts
index 20de66a69..b17a09392 100644
--- a/client/embedded-comments/blog-comments.ts
+++ b/client/embedded-comments/blog-comments.ts
@@ -219,27 +219,26 @@ const mutationObserver = new MutationObserver(function (mutations, observer) {
 mutationObserver.observe(document.body, { subtree: true, childList: true });
 */
 
 
 
-// We store a weak session in localStorage, if 3rd party cookies disabled.
-// It's fairly ok to use localStorage in our case, see:
-//   ../../docs/session-in-local-storage.md
+// For the user to stay logged in, Ty can store not the whole but parts 1 and 2 of
+// the session id in localStorage, if 3rd party cookies disabled.
+// See docs/ty-security.adoc for more about sessions and parts 1, 2, 3, 4 and 5.
+// [NOCOOKIES] Parts 1 and 2 don't let the user do much more than accessing and
+// posting blog comments, and moderating blog comments if hen's a moderator
+// (but won't give access to, for example, the admin area).
+// Some more things to do: [restrict_sid_part_2_more].  [sid_part_3]
 //
-// [NOCOOKIES] [weaksid] ADD_TO_DOCS The session is (will be) "weak", in
-// the sense that, even if you're admin, you cannot use it to go to the admin area
-// and do things there. Then instead you need to login directly to the Talkyard
-// server, rather than on the embedding site via the iframe — so an XSS
-// vulnerability on the embedding site (the blog) cannot give admin access.
-
 let someStorage: Storage | undefined;
 let tempObjStorage;
 
 // Just *looking* at localStorage throws an exception, if cookies blocked,
 // so need try-catch.
 try {
   someStorage = localStorage;
+  window.addEventListener('storage', onLocalStorageChanged);
 }
 catch {
 }
 if (!someStorage) {
   try {
@@ -262,10 +261,28 @@ const theStorage: Storage = someStorage || {
     delete tempObjStorage[key];
   },
 } as Storage;
 
 
+let curSessItemInStorage: St | Nl = null;
+
+function onLocalStorageChanged() {
+  // Maybe we logged out in another browser tab?
+  /* First fix: [forget_sid12]
+  const maybeNewSessItem: St | Nl = theStorage.getItem('talkyardSession');
+  if (curSessItemInStorage !== maybeNewSessItem) {
+    if (maybeNewSessItem) {
+      sendToComments(['resumeWeakSession', maybeNewSessItem]);
+    }
+    else {
+      sendToComments(['logoutServerAndClientSide', null]);
+      sendToEditor(['logoutClientSideOnly', null]);
+    }
+    curSessItemInStorage = maybeNewSessItem;
+  } */
+}
+
 
 addEventListener('scroll', messageCommentsIframeNewWinTopSize);
 addEventListener('message', onMessage, false);
 
 
@@ -861,11 +878,15 @@ function onMessage(event) {
         //
         logM(`Resuming old session`);
         let sessionStr;
         try {
           sessionStr = theStorage.getItem('talkyardSession');
+          // Skip this hereafter?! [btr_sid] Do afterwards instead, if is now invalid.
+          // Because otherwise we'd get logged out in other tabs — they listen to localStorage.
+          // The iframe can send back a 'failedToLogin' instead?  [forget_sid12]
           theStorage.removeItem('talkyardSession');  // see above (3548236)
+          curSessItemInStorage = null;
         }
         catch (ex) {
           logW(`Error getting 'talkyardSession' from theStorage [TyEGETWKSID]`, ex);
         }
         if (sessionStr) {
@@ -934,10 +955,20 @@ function onMessage(event) {
       break;
 
     case 'justLoggedIn':
       const u = eventData.user || {};
       logM(`Logged in as ${u.username || u.fullName} in iframe`);
+      // @ifdef DEBUG
+      // Here, session id part 3 must not be included — it must not be seen
+      // by the embedding website (only by code directly on the Talkyard domain).
+      // So, the length should be:  SidLengthCharsPart1 + SidLengthCharsPart2 = 16 + 24
+      // for the new fancy sessions. Whilst the old silly sids include a '.' dot.
+      if (eventData.weakSessionId && eventData.weakSessionId.length !== 16 + 24
+            && eventData.weakSessionId.indexOf('.') === -1)
+        throw Error(`tySid12 should be 16 + 24 = ${16 + 24} chars but is ${
+                eventData.weakSessionId.length} chars [TyEBADSID12LEN]`);
+      // @endif
       if (eventData.rememberEmbSess) try {
         const item = {
           pubSiteId: eventData.pubSiteId,
           weakSessionId: eventData.weakSessionId,
         };
@@ -958,11 +989,14 @@ function onMessage(event) {
         }
         else {
           // This re-inserts our session (3548236), if we just sent a 'resumeWeakSession'
           // message to the iframe and then removed it from theStorage  — because
           // the comments iframe sends back 'justLoggedIn', after having logged in.
-          theStorage.setItem('talkyardSession', JSON.stringify(item));
+          curSessItemInStorage = JSON.stringify(item);
+          theStorage.setItem('talkyardSession', curSessItemInStorage);
+
+          // ! Need a 'failedToLogin' if login failed  [forget_sid12]
         }
       }
       catch (ex) {
         logW(`Error setting 'talkyardSession' in  theStorage [TyESETWKSID]`, ex);
       }
@@ -971,10 +1005,11 @@ function onMessage(event) {
 
     case 'logoutClientSideOnly':
       logM(`Logged out`);
       try {
         theStorage.removeItem('talkyardSession');
+        curSessItemInStorage = null;
       }
       catch (ex) {
         logW(`Error removing 'talkyardSession' from  theStorage [TyERMWKSID]`, ex);
       }
       sendToOtherIframes(event.data, iframeNr);
diff --git a/client/types-and-const-enums.ts b/client/types-and-const-enums.ts
index 50f16a00a..60a1e013f 100644
--- a/client/types-and-const-enums.ts
+++ b/client/types-and-const-enums.ts
@@ -31,16 +31,18 @@
 // And thanks to St and Nr, lots of line breaks in function signatures,
 // can be avoided. — Really short names, for really frequently used things?
 //
 type U = undefined;
 type Vo = void; // but not 'V' because that's sometimes a 'V'alue template param.
+type V  = void; // ... hmm but really really not often. Let's use 'Va' for such templ params?
 type Nl = null; // but not 'Nu' because that could be "Number".
 type N = null;  // ... hmm I don't think so. One letter U and N for undef & null,
                 // ... and 2 letters for "real" datatypes — easy to remember?
                 // "Nl" is actually a bit hard to read! (the 'l')
 type Ay = any;  // but not 'An' because that sounds like 'an'. Upd: Skip this. Use 'any',
                 // should try to long term do away with all 'any' anyway.
+type A = any;   // Why not A! Let's use 'A'.
 type Bo = boolean;
 type Nr = number;
 type St = string;
 type Bitfield = Nr;  // in Javascript, only 53? bits, the rest is for the exponent [make_opaque_type]
 
@@ -130,12 +132,10 @@ const enum SessionType {
   ServerGlobalOAuth = 2,
   SiteCustomOAuth = 3,
   SiteCustomOSaml = 4,
   SiteCustomSso = 5,
 
-  ApiSecret =  // instead of SidOk("_api_secret_", ..)
-
   EmailSecret = 8,
   BackupCode = 9,
 
   // Combined with bitfield:
   // If pat got logged in without doing anything henself.
diff --git a/conf/routes b/conf/routes
index 56934cd5f..582e34a73 100644
--- a/conf/routes
+++ b/conf/routes
@@ -161,10 +161,14 @@ POST  /-/set-primary-email-address controllers.UserController.setPrimaryEmailAdd
 POST  /-/add-email-address      controllers.UserController.addUserEmail
 POST  /-/remove-email-address   controllers.UserController.removeUserEmail
 GET   /-/confirm-email-address  controllers.UserController.confirmOneMoreEmailAddress(confirmationEmailId)
 POST  /-/resend-email-addr-verif-email controllers.UserController.resendEmailAddrVerifEmail
 
+GET   /-/list-sessions          talkyard.server.sess.SessionController.listSessions(patId: Int)
+POST  /-/terminate-sessions     talkyard.server.sess.SessionController.terminateSessions
+# Maybe move /-/logout to SessionController?
+
 GET   /-/unsubscribe            controllers.UnsubscriptionController.showForm(emailId)
 POST  /-/unsubscribe            controllers.UnsubscriptionController.handleForm(emailId)
 GET   /-/unsubscribed           controllers.UnsubscriptionController.showHasBeenUnsubscribed()
 
 GET   /-/unsub-from-summaries   ed.server.summaryemails.UnsubFromSummariesController.showUnsubForm(emailId)
diff --git a/docs/maybe-do-later.txt b/docs/maybe-do-later.txt
index 86fd2d92e..76c3a8c25 100644
--- a/docs/maybe-do-later.txt
+++ b/docs/maybe-do-later.txt
@@ -90,10 +90,13 @@ FIXED, now uses 1st party cookie or storage: [privacy-badger]:
 Privacy Badger doesn't like transient session cookies, currently.
 (See https://github.com/EFForg/privacybadger/issues/2003#issuecomment-391745819  bullet point 3. )
 So maybe change from csrf Double Submit Cookie, to Encrypted Token Pattern?
 https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Encrypted_Token_Patternp
 
+[gdpr]
+New table: sessions_t, impl fn deletePersonalDataFromOldSessions().  [sess_deleted_ix]
+
 [pps]
 Rename User to Participant and User to Member.  Participant = Guest or Member, Member = User or Group.
 Rename variables named like "member" to "user", "memberOrGroup" to "member", "user" to "participant"
 Rename "indie" to user" and "people" to "members"
 
@@ -556,12 +559,15 @@ the server is offline.  And, the HttpOnly session offers a bit extra security.
 [serversid]
 Server side sesison ids, maybe in Redis? So can log ppl out, without having access
 to their browsers to delete session cookies.
 (Also see [offline-first] above: need two cookies.)
 Include session type (e.g. pwd, oauth, email secret, sso, emb sso) in sid:s,
-see: SessionType.AutoTokenSiteCustomSso, and: SidOk("_api_secret_".
-Also see: [weaksid].
+see: SessionType.AutoTokenSiteCustomSso, and: SidOk(TySession.ApiSecretPart12, ..)
+Also see: [weaksid]. [which_ws_session]
+Add [sess_deleted_ix]
+And [btr_sid].
+Missing:  [forget_sid12] [restrict_sid_part_2_more] [bump_sid_last_use]
 
 [sess_in_me]
 Store xsrf token and any sid-part-1 in Me, instead of win.typs?
 
  
@@ -865,10 +871,19 @@ About how to write docs:
 
 [ios_bugs]
 Problems that happen only on iOS Safari (and Chrome, but all browsers are basically
 Safari on iOS). When works elsewhere, this might be bugs in iOS Safari.
 
+[ios_itp]
+Trigger happy tracking prevention produces problems.
+Maybe maybe Safari [ios_itp] will sometimes mistake email verification links
+or SSO url params, for being click trackers — e.g. Talkyard's
+#talkyardOneTimeLoginSecret=... hash fragment param. See:
+https://webkit.org/blog/9521/intelligent-tracking-prevention-2-3/
+— then, Safari would delete the Ty session parts 1+2 from localStorage,
+after a day.
+https://www.cookiestatus.com/safari/
 
 [default_plugins]
 KaTeX for Maths (VSCode supports KaTeX, and Docusaurus too:
 https://docusaurus.io/docs/markdown-features/math-equations )
 Prism for syntax highlighting (Docusaurus uses it:
diff --git a/docs/naming-notes.md b/docs/naming-notes.md
index 752844c09..8ebaff930 100644
--- a/docs/naming-notes.md
+++ b/docs/naming-notes.md
@@ -2,10 +2,11 @@ Talkyard Naming Notes
 ==========================
 
 `ttt` means Test the test. That is, some [assertion in a test] that doesn't test
 the real Talkyard code — instead, it tests the test code. (There's no `tttt`)
 
+`reidr` means "Re-Enter Iframe in case you just Debug-Reloaded the page".
 
 ### People
 
 A "Participant" is anyone with an account at the relevant Talkyard site,
 that is: guests (anonymous users), groups, users incl bots.
diff --git a/docs/tests-map.txt b/docs/tests-map.txt
index de0a865b3..38056f41f 100644
--- a/docs/tests-map.txt
+++ b/docs/tests-map.txt
@@ -237,10 +237,38 @@ impersonate:
           - impersonate-restricted-areas.test.ts  TyT6502PKSNR57
 
 different users -
   login, logout, switch user:  almost all tests
 
+sessions:
+  list one's active sessions,
+  terminate one's other sessions:
+            - d.sessions-logout-elsewhere.4br.ts  TyTESESLGOSELF
+  staff can terminate others' sessions:
+            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR
+  mods cannot terminate admins sessions:
+            - TESTS_MISSING  TyTESESTERMADM
+            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR  TyEADMINSESS_
+  require session part 4,
+  a stolen embdding storage sid part 1+2 is pretty useless:
+            - embcom.sessions-emb-sess-cannot-moderate.3br  TyTEECSESS0MOD
+  require part 4 HttpOnly for admin stuff:
+            - TESTS_MISSING
+  require part 4 HttpOnly for non-embedded comments stuff:
+            - TESTS_MISSING, and partly UNIMPL
+  part 123 gone, client side offline logout:
+            - sso-test  TyT4ABKRW0268.TyTESESS123GONE
+            - TESTS_MISSING could add tests for this specifically
+  part 123 bad,
+  part 4 or 5 bad or gone:
+            - TESTS_MISSING  TyTSESSHALFBAD
+  sorted by recently active first, on one's user page:
+            - TESTS_MISSING
+  session terminated if user suspended or deleted:  [.del_susp_usr]
+            - TESTS_MISSING
+
+
 navigation:
   from anywhere, to anywhere:
           - navigation-as-stranger.test.ts  TyT5WKAB024
           - navigation-as-member.test.ts  TyT2ABKR593
           - navigation-as-admin.test.ts  TyT7WAAR2J4
@@ -249,10 +277,12 @@ navigation:
           - modn-from-disc-page-appr-befr.2browsers.test.ts  TyTE2E603RTJ.TyTE2E603SKD
   from user profile topic list to page:
             - tags-basic.2br  TyTE2ETAGSBSC.TyTNAVUSR2PG
   from user profile posts list to page:
             - badges-basic.2br  TyTE2EBADGESBSC.TyTNAVUSR2PO
+  from page to one's active sessions list:
+            - d.sessions-logout-elsewhere.4br  TyTESESLGOSELF
 
 profile page, user:
   upload photo:  (8PK2)
   edit full name:
   change username:
@@ -352,10 +382,12 @@ security:
   cors:
             - api-search-ext-site-and-server.2br.cors  TyTEAPIEXTSRV
             - embcom.comment-counts.2br.cors.test.ts   TyTE2ECOMCOUNTS
             - embcom.manyframes.comment-counts.2br.cors  TyTE2EMNYFRCOMCNTS
 
+deleted users:  [.del_susp_usr]
+            - SEC_TEST_MISSING
 suspended users:
   gets no email notfs,
   websocket gets disconnected,
   cannot access login-required site:
           - login-required-join-global-chat.2br  TyTE2E603MAT53
@@ -1008,10 +1040,13 @@ admin:
   cannot bad-click break an admin, e.g. unverifying hens email or unapproving, rejecting:
           - (missing) [5AEWBN0]
   members tab:
     really many members:
           - many-users-mention-list-join-group.2browsers.test.ts   TESTS_MISSING  TyT60295KTDT
+  terminate someone else's sessions:
+            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR
+
 
 moderation,
 approve before,
 review after:
     ninja edits,
@@ -1326,10 +1361,15 @@ live notifications -
   misc chat:
           - login-required-join-global-chat.2br.test.ts  TyTE2E603MAT53
           - promote-demote-by-staff-join-leave-chats.2br.test.ts  TyTE2E5H3GFRVK
   disconnected from chat, if removed from chat:
           - SEC_TESTS_MISSING  TyT6P03MRKD
+  no more WebSocket messages,
+    if account deleted,
+    or if suspended,
+    or session terminated:
+          - SEC_TESTS_MISSING  TyTE0MOREWSMSG
   presence:
             - tags-badges-not-missing.2br  TyTETAGS0MISNG.TyTPRESENCE01
             - TESTS_MISSING  could add more, also for going offline
 
 notf prefs inherited:
diff --git a/s/impl/tyd-e2e-tests.ts b/s/impl/tyd-e2e-tests.ts
index 5648c4fd4..6e54be9cd 100644
--- a/s/impl/tyd-e2e-tests.ts
+++ b/s/impl/tyd-e2e-tests.ts
@@ -243,11 +243,11 @@ function runE2eTests(): U | ExitCode | 'TestsCancelled' {
 
 
 
   const skipAlways = ['!UNIMPL', '!-impl.', '!imp-exp-imp-exp-site'];
   const skipEmbAndAlways = ['!embcom', '!embedded-', '!embforum.', ...skipAlways]
-  const skip2And3Browsers = ['!.2br', '!.3br'];
+  const skip2And3Browsers = ['!.2br', '!.3br', '!.4br'];  // and skip 4 as well
 
 
   // ----- 1 browser
 
   let next: St[] | St[][] = [...skip2And3Browsers, ...skipEmbAndAlways];
@@ -291,10 +291,23 @@ function runE2eTests(): U | ExitCode | 'TestsCancelled' {
   withSpecsMatching(next, (specs: St[]): ExitCode => {
     return runWdioInForeground(specs, '--3browsers');
   });
 
 
+  // ----- 4 browsers
+
+  // Just these, currently:
+  // d.sessions-logout-elsewhere.4br  TyTESESLGOSELF
+  // d.sessions-staff-logout-others.4br  TyTESESLGOOTR
+
+  next = ['.4br', ...skipEmbAndAlways];
+
+  withSpecsMatching(next, (specs: St[]): ExitCode => {
+    return runWdioInForeground(specs, '--4browsers');   // UNTESTED
+  });
+
+
   // ----- 1 browser, embedded comments
 
   const skip23BrAndUnusualEmb = ['!b3c.', '!gatsby', '!embforum.',
           ...skip2And3Browsers, ...skipAlways];
   // Accidentally different file names.
diff --git a/s/run-e2e-tests.sh b/s/run-e2e-tests.sh
index bd6a21dc5..2dc3da2e7 100755
--- a/s/run-e2e-tests.sh
+++ b/s/run-e2e-tests.sh
@@ -308,10 +308,13 @@ function runAllE2eTests {
   $r s/wdio --only navigation-as-admin $args
   $r s/wdio --only navigation-as-member $args
   $r s/wdio --only navigation-as-stranger $args
   # Also:  embedded-comments-navigation-as-guest  further below.
 
+  $r s/wdio-7 --only d.sessions-logout-elsewhere.4br --cd -i $args
+  $r s/wdio-7 --only d.sessions-staff-logout-others.4br --cd -i $args
+
   $r s/wdio --only hide-unhide-tips.2br $args
   $r s/wdio-7 --only show-admin-notices.2br --cd -i $args
 
   $r s/wdio-7 --only editor-toolbar-preview.1br --cd -i $args
   $r s/wdio --only sanitize-posts.2browsers $args
@@ -645,10 +648,13 @@ function runAllE2eTests {
   $r s/wdio-7     --only embcom.manyframes.basic.2br --cd -i $args
   $r s/wdio-7     --only embcom.manyframes.drafts-repl-to.2br --cd -i $args
   $r s/wdio-7     --only embcom.manyframes.js-api.2br --cd -i $args
   $r s/wdio-7     --only embcom.manyframes.comment-counts.2br.cors --cd -i $args
 
+  # Stealing an embedded session id
+  $r s/wdio-7     --only embcom.sessions-emb-sess-cannot-moderate.3br --cd -i $args
+
 
 
   if [ -n "$http_server_pid" ]; then
     kill $http_server_pid
     echo "Stopped the http server for the embedded comments, pid $http_server_pid."
diff --git a/tests/app/debiki/dao/UserStatsAppSpec.scala b/tests/app/debiki/dao/UserStatsAppSpec.scala
index f39bd547a..cff1c477b 100644
--- a/tests/app/debiki/dao/UserStatsAppSpec.scala
+++ b/tests/app/debiki/dao/UserStatsAppSpec.scala
@@ -121,10 +121,14 @@ class UserStatsAppSpec extends DaoAppSuite() {
     }
 
     "... logs out, last-seen stats get updated" in {
       playTimeMillis(1000)
       dao.logout(member1, bumpLastSeen = true)
+
+      // Maybe better end any sessions — although aren't any.
+      dao.terminateSessions(forPatId = member1.id, all = true)
+
       val stats = loadUserStats(member1.id)(dao)
       stats mustBe initialStats.copy(lastSeenAt = currentTime)
     }
 
     "... logs in, stats get updated" in {
diff --git a/tests/e2e-wdio7/specs/d.sessions-logout-elsewhere.4br.e2e.ts b/tests/e2e-wdio7/specs/d.sessions-logout-elsewhere.4br.e2e.ts
new file mode 100644
index 000000000..726cade54
--- /dev/null
+++ b/tests/e2e-wdio7/specs/d.sessions-logout-elsewhere.4br.e2e.ts
@@ -0,0 +1,243 @@
+/// <reference path="../test-types.ts"/>
+
+import * as _ from 'lodash';
+import assert from '../utils/ty-assert';
+import server from '../utils/server';
+import { buildSite } from '../utils/site-builder';
+import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';
+
+let brA: TyE2eTestBrowser;
+let brB: TyE2eTestBrowser;
+let brC: TyE2eTestBrowser;
+let brD: TyE2eTestBrowser;
+let owen: Member;
+let owen_brA: TyE2eTestBrowser;
+let owen_brB: TyE2eTestBrowser;
+let owen_brC: TyE2eTestBrowser;
+let maja: Member;
+let maja_brD: TyE2eTestBrowser;
+
+let site: IdAddress;
+let forum: TwoCatsTestForum;
+
+
+
+describe(`d.sessions-logout-elsewhere.4br  TyTESESLGOSELF`, () => {
+
+  it(`Construct site`, async () => {
+    const builder = buildSite();
+    forum = builder.addTwoCatsForum({
+      title: "Some E2E Test",
+      members: ['owen', 'maja'],
+    });
+
+    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
+    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
+    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');
+    brD = new TyE2eTestBrowser(wdioBrowserD, 'brD');
+
+    owen = forum.members.owen;
+    owen_brA = brA;
+    owen_brB = brB;
+    owen_brC = brC;
+
+    maja = forum.members.maja;
+    maja_brD = brD;
+
+    assert.refEq(builder.getSite(), forum.siteData);
+  });
+
+  it(`Import site`, async () => {
+    site = server.importSiteData(forum.siteData);
+    server.skipRateLimits(site.id);
+  });
+
+
+  it(`Owen logs in to the admin area, in browser A ...`, async () => {
+    await owen_brA.adminArea.settings.login.goHere(site.origin, { loginAs: owen });
+  });
+  it(`... and to the forum, in browser B`, async () => {
+    await owen_brB.go2(site.origin);
+    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
+  });
+  it(`... and C`, async () => {
+    await owen_brC.go2(site.origin);
+    await owen_brC.complex.loginWithPasswordViaTopbar(owen);
+  });
+
+
+  it(`Maja logs in, in browser D`, async () => {
+    await maja_brD.go2(site.origin);
+    await maja_brD.complex.loginWithPasswordViaTopbar(maja);
+  });
+
+
+  it(`Owen starts editing admin settings in browser A, without saving`, async () => {
+    await owen_brA.adminArea.settings.login.setApproveUsers(true);
+  });
+  it(`... and starts composing a new topic in browser B`, async () => {
+    await owen_brB.forumButtons.clickCreateTopic();
+    await owen_brB.editor.editTitle("Title");
+    await owen_brB.editor.editText("Text");
+  });
+
+
+  // ----- List active sessions
+
+  it(`In browser C, Owen navigates to his profile page`, async () => {
+    await owen_brC.topbar.clickGoToProfile();
+  });
+  it(`... the active sessions list`, async () => {
+    await owen_brC.userProfilePage.tabs.switchToPreferences();
+    await owen_brC.userProfilePage.preferences.tabs.switchToSecurity();
+  });
+
+  it(`Owen sees 3 active sessions`, async () => {
+    const counts = await owen_brC.userProfilePage.preferences.security.countSessions();
+    assert.deepEq(counts, { numActive: 3, numEnded: 0 });
+  });
+
+
+  // ----- Terminate all one's other sessions
+
+  it(`Owen treminates all sessions except for browser C`, async () => {
+    await owen_brC.userProfilePage.preferences.security.terminateAllSessions();
+  });
+
+  it(`... now he sees one active session, and 2 terminated sessions`, async () => {
+    // It takes a while for the server to reply, and the UI to update.
+    await owen_brC.userProfilePage.preferences.security.waitForSessions({
+            numActive: 1, numEnded: 2 });
+  });
+  it(`... after page reload ...`, async () => {
+    await owen_brC.refresh2();
+    await owen_brC.userProfilePage.preferences.security.waitForLoaded();
+  });
+  it(`... the terminated sessions are gone — terminated sessions aren't shown`, async () => {
+    const counts = await owen_brC.userProfilePage.preferences.security.countSessions();
+    assert.deepEq(counts, { numActive: 1, numEnded: 0 });
+  });
+
+
+  // ----- Cannot use terminated sessions
+
+  it(`In browser A, Owen tries to save the admin settings`, async () => {
+    await owen_brA.adminArea.settings.clickSaveAll({ willFail: true });
+  });
+  it(`... there's an error: Owen isn't logged in as admin (in brA)`, async () => {
+    await owen_brA.serverErrorDialog.waitForNotLoggedInAsAdminError();
+  });
+  it(`... after page refresh, he sees the login dialog (in brA)`, async () => {
+    await owen_brA.refresh2();
+    await owen_brA.loginDialog.waitAssertFullScreen();
+  });
+
+  it(`In browser B, Owen tries to save the topic`, async () => {
+    await owen_brB.editor.save();
+  });
+  it(`... there's a not-logged-in error in brB`, async () => {
+    await owen_brB.serverErrorDialog.waitForNotLoggedInError();
+  });
+  it(`... after page refresh, he sees he's logged out in brB`, async () => {
+    await owen_brB.refresh2();
+    await owen_brB.topbar.waitUntilLoginButtonVisible();
+  });
+
+
+  // ----- Login again
+
+  it(`Owen logs in again, in browser B`, async () => {
+    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
+  });
+  it(`... and logs in to his sessions list page, in browser A ...`, async () => {
+    await owen_brA.userProfilePage.preferences.security.goHere(owen.username, { loginAs: owen });
+  });
+
+  it(`There are again 3 active sessions`, async () => {
+    const counts = await owen_brA.userProfilePage.preferences.security.countSessions();
+    assert.deepEq(counts, { numActive: 3, numEnded: 0 });
+  });
+
+
+  // ----- Session sort order
+
+  it(`... Owen's brA session is in the middle   TESTS_MISSING
+              because sessions are sorted by created-at currently`, async () => {
+  });
+
+
+  // ----- Terminate a specific session
+
+  it(`Owen terminates the oldest session, the one in brC`, async () => {
+    await owen_brA.userProfilePage.preferences.security.terminateOldestSession();
+  });
+  it(`... thereafter, 2 active and 1 teminated sessions listed`, async () => {
+    await owen_brA.userProfilePage.preferences.security.waitForSessions({
+            numActive: 2, numEnded: 1 });
+  });
+  it(`ttt: There're 2 terminate-session buttons: One to terminate "all", and one
+              to terminate the one and only other session`, async () => {
+    const n = await owen_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
+    assert.eq(n, 2);
+  });
+
+  it(`Now, in brC, Owen tries to switch to the Drafts Etc tab`, async () => {
+    await owen_brC.userProfilePage.tabs.switchToDraftsEtc({ willFail: true });
+  });
+  it(`... there's an error: He's no longer logged in, in that browser`, async () => {
+    await owen_brC.serverErrorDialog.waitForNotLoggedInError();
+  });
+  it(`... after reload ...`, async () => {
+    await owen_brC.refresh2();
+  });
+  it(`... the login button appears`, async () => {
+    await owen_brC.topbar.waitUntilLoginButtonVisible();
+  });
+
+
+  // ----- One's other sessions still work
+
+  it(`But in brB, Owen is still logged in: He can submit a topic`, async () => {
+    await owen_brB.complex.createAndSaveTopic({ title: "Owen's Topic", body: "Text." });
+  });
+  it(`... and after reload ...`, async () => {
+    await owen_brB.refresh2();
+  });
+  it(`... he's still logged in — sees his username menu`, async () => {
+    assert.eq(await owen_brB.topbar.getMyUsername(), owen.username);
+  });
+
+
+  // ----- Other users not affected
+
+  it(`Maja is still logged in — she can post a topic`, async () => {
+    await maja_brD.complex.createAndSaveTopic({ title: "Maja's Topic", body: "Text." });
+  });
+  it(`... and reload the page`, async () => {
+    await maja_brD.refresh2();
+  });
+  it(`... she still sees her username — she's still logged in`, async () => {
+    await maja_brD.topbar.assertMyUsernameMatches(maja.username);
+  });
+
+
+  // ----- Session disappears from list, after logging out in other browser
+
+  it(`Owen logs out in browser B`, async () => {
+    await owen_brB.topbar.clickLogout();
+  });
+
+  it(`... and reloads the session list in browser A`, async () => {
+    await owen_brA.refresh2();
+  });
+  it(`... now there's just one active session — his current session, brA`, async () => {
+    const counts = await owen_brA.userProfilePage.preferences.security.countSessions();
+    assert.deepEq(counts, { numActive: 1, numEnded: 0 });
+  });
+
+  it(`There's no end-session button — cannot end one's current session here`, async () => {
+    const n = await owen_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
+    assert.eq(n, 0);
+  });
+
+});
diff --git a/tests/e2e-wdio7/specs/d.sessions-staff-logout-others.4br.e2e.ts b/tests/e2e-wdio7/specs/d.sessions-staff-logout-others.4br.e2e.ts
new file mode 100644
index 000000000..c6b2f073b
--- /dev/null
+++ b/tests/e2e-wdio7/specs/d.sessions-staff-logout-others.4br.e2e.ts
@@ -0,0 +1,299 @@
+/// <reference path="../test-types.ts"/>
+
+import * as _ from 'lodash';
+import assert from '../utils/ty-assert';
+import server from '../utils/server';
+import { buildSite } from '../utils/site-builder';
+import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';
+import settings from '../utils/settings';
+
+let brA: TyE2eTestBrowser;
+let brB: TyE2eTestBrowser;
+let brC: TyE2eTestBrowser;
+let brD: TyE2eTestBrowser;
+let owen: Member;
+let owen_brB: TyE2eTestBrowser;
+let mons: Member;
+let mons_brA: TyE2eTestBrowser;
+let maja: Member;
+let maja_brB: TyE2eTestBrowser;
+let mallory: Member;
+let mallory_brC: TyE2eTestBrowser;
+let mallory_brD: TyE2eTestBrowser;
+
+let site: IdAddress;
+let forum: TwoCatsTestForum;
+
+
+
+describe(`d.sessions-staff-logout-others.4br  TyTESESLGOOTR`, () => {
+
+  it(`Construct site`, async () => {
+    const builder = buildSite();
+    forum = builder.addTwoCatsForum({
+      title: "Some E2E Test",
+      members: ['owen', 'maja', 'mons', 'mallory'],
+    });
+
+    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
+    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
+    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');
+    brD = new TyE2eTestBrowser(wdioBrowserD, 'brD');
+
+    owen = forum.members.owen;
+    owen_brB = brB;
+
+    mons = forum.members.mons;
+    mons_brA = brA;
+
+    maja = forum.members.maja;
+    maja_brB = brB;
+
+    mallory = forum.members.mallory;
+    mallory_brC = brC;
+    mallory_brD = brD;
+
+    assert.refEq(builder.getSite(), forum.siteData);
+  });
+
+  it(`Import site`, async () => {
+    site = server.importSiteData(forum.siteData);
+    server.skipRateLimits(site.id);
+  });
+
+
+  // ----- (Maja and Mallory log in)
+
+  it(`Maja logs in, in browser B`, async () => {
+    await maja_brB.go2(site.origin);
+    await maja_brB.complex.loginWithPasswordViaTopbar(maja);
+  });
+
+  it(`Mallory too, in browser C ...`, async () => {
+    await mallory_brC.go2(site.origin);
+    await mallory_brC.complex.loginWithPasswordViaTopbar(mallory);
+  });
+
+  it(`Mallory logs in in browser D too`, async () => {
+    await mallory_brD.userProfilePage.preferences.goHere(
+            mallory.username, { origin: site.origin });
+    await mallory_brD.complex.loginWithPasswordViaTopbar(mallory);
+  });
+  it(`... starts editing his name`, async () => {
+    await mallory_brD.userProfilePage.preferences.setFullName('Admin of Admins');
+  });
+
+
+  // ----- Cannot view someone else's sessions (unless one is mod)
+
+  it(`Mallory types the URL path to Maja's Security tab, in his other browser, C`, async () => {
+    await mallory_brC.userProfilePage.preferences.security.goHere(maja);
+  });
+
+  it(`... there's nothing here to see — cannot view others' sessions`, async () => {
+    await mallory_brC.userProfilePage.waitForTabsVisible();
+    assert.not(await mallory_brC.userProfilePage.tabs.isPreferencesTabDisplayed());
+    assert.not(await mallory_brC.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
+  });
+
+  it(`Mallory returns to the forum topic list instead (in browser C)`, async () => {
+    await mallory_brC.topbar.clickBack();
+  });
+
+
+  // ----- A mod terminates a specific session
+
+  it(`Moderator Mons goes to Mallory's profile page`, async () => {
+    await mons_brA.userProfilePage.preferences.goHere(mallory.username, { origin: site.origin });
+    await mons_brA.complex.loginWithPasswordViaTopbar(mons);
+  });
+  it(`... sees the Preferences tab  ttt`, async () => {
+    await mons_brA.userProfilePage.waitForTabsVisible();
+    // ttt — see assert.not(..) above.
+    assert.that(await mons_brA.userProfilePage.tabs.isPreferencesTabDisplayed());
+  });
+  it(`... and the About and Security tab`, async () => {
+    await mons_brA.userProfilePage.preferences.tabs.waitForAboutTabDisplayed();
+    // ttt — see assert.not(..) above.
+    assert.that(await mons_brA.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
+  });
+  it(`... switches to the Security tab`, async () => {
+    await mons_brA.userProfilePage.preferences.tabs.switchToSecurity();
+  });
+
+  it(`Mons sees Mallory's 2 sessions`, async () => {
+    const num = await mons_brA.userProfilePage.preferences.security.countSessions();
+    assert.deepEq(num, { numActive: 2, numEnded: 0 });
+  });
+
+  it(`Mons terminates Mallory's newest session (that's browser D)`, async () => {
+    await mons_brA.userProfilePage.preferences.security.terminateNewestSession();
+  });
+
+  it(`... afterwards, he sees one active and one terminated session`, async () => {
+    await mons_brA.userProfilePage.preferences.security.waitForSessions({
+            numActive: 1, numEnded: 1 });
+  });
+
+
+  // ----- Cannot use a terminated session
+
+  it(`Mallory tries to save his username (in browser D)`, async () => {
+    await mallory_brD.userProfilePage.preferences.clickSave();
+  });
+  it(`... there's an error: Mallory isn't logged in (now in brD)`, async () => {
+    await mallory_brD.serverErrorDialog.waitForNotLoggedInError();
+  });
+  it(`... after page refresh ...`, async () => {
+    await mallory_brD.refresh2();
+  });
+  it(`... he sees the login button (in brD)`, async () => {
+    await mallory_brD.me.waitUntilKnowsNotLoggedIn();
+    await mallory_brD.topbar.waitUntilLoginButtonVisible();  // ttt
+  });
+
+  it(`In browser C, Mallory is still logged in — he posts a new topic`, async () => {
+    await mallory_brC.complex.createAndSaveTopic({
+          title: "Mallory's Place", body: "This is my palace, I, the Admin of Admins" });
+  });
+
+
+  // ----- (Logging in again)
+
+  it(`Mallory logs in again in browser D`, async () => {
+    await mallory_brD.complex.loginWithPasswordViaTopbar(mallory);
+  });
+
+
+  // ----- A mod terminates all a user's sessions
+
+  it(`Mons refreshes the sessions list`, async () => {
+    await mons_brA.refresh2();
+  });
+
+  it(`... sees Mallory is back, two active sessions`, async () => {
+    const num = await mons_brA.userProfilePage.preferences.security.countSessions();
+    assert.deepEq(num, { numActive: 2, numEnded: 0 });
+  });
+
+  it(`... there're terminate buttons for all sessions, plus an all-in-one-click`, async () => {
+    const n = await mons_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
+    assert.eq(n, 2 + 1);
+  });
+
+  it(`Mons terminates all Mallory's sessions`, async () => {
+    await mons_brA.userProfilePage.preferences.security.terminateAllSessions();
+  });
+
+  it(`... afterwards, no more active sessions, 2 terminated`, async () => {
+    await mons_brA.userProfilePage.preferences.security.waitForSessions({
+            numActive: 0, numEnded: 2 });
+  });
+
+  it(`... no more terminate buttons`, async () => {
+    const n = await mons_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
+    assert.eq(n, 0);
+  });
+
+
+  // ----- Cannot use the terminated sessions
+
+  it(`Mallory tries to reply to and agree with himself (in brC)`, async () => {
+    await mallory_brC.topic.clickReplyToOrigPost();
+  });
+  it(`... but there's a not-logged-in error`, async () => {
+    await mallory_brC.serverErrorDialog.waitForNotLoggedInError();
+  });
+  it(`... after page refresh ...`, async () => {
+    await mallory_brC.refresh2();
+  });
+  it(`... he sees he's logged out, sees the login button`, async () => {
+    await mallory_brC.me.waitUntilKnowsNotLoggedIn();
+    await mallory_brC.topbar.waitUntilLoginButtonVisible();  // ttt
+  });
+
+  it(`He's logged out in the other browser, brD, too`, async () => {
+    await mallory_brD.refresh2();
+    await mallory_brD.me.waitUntilKnowsNotLoggedIn();
+  });
+
+
+  // ----- Other users not affected
+
+  it(`Maja is still logged in: She can submit a topic`, async () => {
+    await maja_brB.complex.createAndSaveTopic({ title: "Important about cream",
+            body: "Is milk cream the healthiest dinner, with or without sugar" });
+  });
+
+  it(`... and after reload ...`, async () => {
+    await maja_brB.refresh2();
+  });
+
+  it(`... she's still logged in — sees her username menu`, async () => {
+    assert.eq(await maja_brB.topbar.getMyUsername(), maja.username);
+  });
+
+
+  // ----- Mods cannot terminate admins' sessions
+
+  it(`Owen logs in`, async () => {
+    await maja_brB.topbar.clickLogout();
+    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
+  });
+
+  it(`Mons goes to Owen's profile page, the Preferences tab`, async () => {
+    await mons_brA.userProfilePage.preferences.goHere(owen.username);
+  });
+
+  it(`... sees the About tab to the left ...`, async () => {
+    await mons_brA.userProfilePage.preferences.tabs.waitForAboutTabDisplayed();
+  });
+  it(`... but there's no Security tab! Cannot view admin's sessions  UNIMPL TESTS_MISSING TyTESESTERMADM`, async () => {
+    // hmm there is, but it won't work.
+    //assert.not(await mons_brA.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
+  });
+
+  it(`Mons instead types the URL path to Owen's Security tab`, async () => {
+    await mons_brA.userProfilePage.preferences.security.goHere(owen);
+  });
+  it(`... there's an error: he's not allowed to view an admin's sessions`, async () => {
+    await mons_brA.serverErrorDialog.waitAndAssertTextMatches('TyEADMINSESS_');
+    await mons_brA.serverErrorDialog.close();
+  });
+
+
+  // ----- Admins can terminate mods' sessions
+
+  it(`Owen jumps to Mon's Security tab`, async () => {
+    await owen_brB.userProfilePage.preferences.security.goHere(mons);
+  });
+
+  it(`... logs Mons out`, async () => {
+    await owen_brB.userProfilePage.preferences.security.terminateAllSessions();
+  });
+
+  it(`... now there's 1 terminated session`, async () => {
+    await owen_brB.userProfilePage.preferences.security.waitForSessions({
+            numActive: 0, numEnded: 1 });
+  });
+
+
+  // ----- Session won't work afterwards
+
+  it(`Mons tries to view invites Owen has sent`, async () => {
+    await mons_brA.userProfilePage.switchToInvites({ willFail: true });
+  });
+  it(`... but there's a not-logged-in error`, async () => {
+    await mons_brA.serverErrorDialog.waitForNotLoggedInError();
+  });
+  it(`... Mons reloads the page ...`, async () => {
+    await mons_brA.refresh2();
+  });
+  it(`... Mons sees he's logged out, sees the login button`, async () => {
+    await mons_brA.me.waitUntilKnowsNotLoggedIn();
+    await mons_brA.topbar.waitUntilLoginButtonVisible();  // ttt
+  });
+  
+
+});
+
diff --git a/tests/e2e-wdio7/specs/embcom.expimpjson.import-tests-impl.ts b/tests/e2e-wdio7/specs/embcom.expimpjson.import-tests-impl.ts
index 070fa671e..3ce3edaba 100644
--- a/tests/e2e-wdio7/specs/embcom.expimpjson.import-tests-impl.ts
+++ b/tests/e2e-wdio7/specs/embcom.expimpjson.import-tests-impl.ts
@@ -213,13 +213,19 @@ function constructEmbCommentsImportTest(testName: string, variants: {
 
       it(`There's a Done Restoing Backup message`, async () => {
         await owensBrowser.waitForExist('.e_RstrDne');
       });
 
-      it(`Owen remains logged in?  Or needs to login again?`, async () => {
+      it(`Owen reloads the page, as per the instructions ...`, async () => {
         await owensBrowser.refresh();
-        // Hmm, currently:
+      });
+
+      it(`... needs to log in again — sessions_t curently not exported`, async () => {
+        await owensBrowser.loginDialog.loginWithPassword(owen);
+      });
+
+      it(`... he's Owen again`, async () => {
         await owensBrowser.topbar.assertMyUsernameMatches(owen.username);
       });
     }
     else if (variants.importToExistingEmptyForumSiteViaApi) {
       lad.die("unimplemented [TyE206MKTT3]");
diff --git a/tests/e2e-wdio7/specs/embcom.sessions-emb-sess-cannot-moderate.3br.e2e.ts b/tests/e2e-wdio7/specs/embcom.sessions-emb-sess-cannot-moderate.3br.e2e.ts
new file mode 100644
index 000000000..437b01a01
--- /dev/null
+++ b/tests/e2e-wdio7/specs/embcom.sessions-emb-sess-cannot-moderate.3br.e2e.ts
@@ -0,0 +1,332 @@
+/// <reference path="../test-types.ts"/>
+
+import * as _ from 'lodash';
+import assert from '../utils/ty-assert';
+import * as fs from 'fs';
+import server from '../utils/server';
+import * as utils from '../utils/utils';
+import { buildSite } from '../utils/site-builder';
+import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';
+import { logMessage, j2s, logBoring } from '../utils/log-and-die';
+import c from '../test-constants';
+import { IsWhere } from '../test-types';
+
+let brA: TyE2eTestBrowser;
+let brB: TyE2eTestBrowser;
+let brC: TyE2eTestBrowser;
+let owen: Member;
+let owen_brA: TyE2eTestBrowser;
+let alice: Member;
+let alice_brC: TyE2eTestBrowser;
+let mons: Member;
+let mons_brA: TyE2eTestBrowser;
+let maja: Member;
+let maja_brA: TyE2eTestBrowser;
+let mallory: Member;
+let mallory_brB: TyE2eTestBrowser;
+
+// Try to remember to update, if adding more users?
+const majasUserId = 104;
+
+const weakSidErrCode = 'TyEWEAKSID_';  // move to test constants?
+
+const localHostname = 'comments-for-e2e-test-stealembs-localhost-8080';
+const embeddingOrigin = 'http://e2e-test-stealembs.localhost:8080';
+let embeddingPageSlug = 'emb-sess-stealer.html';
+let embeddingPageUrl = embeddingOrigin + '/' + embeddingPageSlug;
+
+let site: IdAddress;
+let forum: TwoCatsTestForum;
+
+let monsLocalStorageSessionJsonSt = '';
+
+let stolenSidPart1And2WasMons = '';
+
+let stolenSidPart1OnlyWasOwens = '';
+let stolenSidPart1And2WasOwens = '';
+
+
+describe(`embcom.sessions-emb-sess-cannot-moderate.3br  TyTEECSESS0MOD`, () => {
+
+  it(`Construct site`, async () => {
+    const builder = buildSite();
+    forum = builder.addTwoCatsForum({
+      title: "Emb Comments Sessions Cannot Moderate",
+      members: ['owen', 'alice', 'mons', 'maja', 'mallory'],
+    });
+
+    builder.getSite().meta.localHostname = localHostname;
+    builder.getSite().settings.allowEmbeddingFrom = embeddingOrigin;
+
+    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
+    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
+    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');
+
+    owen = forum.members.owen;
+    owen_brA = brA;
+
+    mons = forum.members.mons;
+    mons_brA = brA;
+
+    maja = forum.members.maja;
+    maja_brA = brA;
+
+    mallory = forum.members.mallory;
+    mallory_brB = brB;
+
+    alice = forum.members.alice;
+    alice_brC = brC;
+
+    assert.refEq(builder.getSite(), forum.siteData);
+  });
+
+  it(`Import site`, async () => {
+    site = server.importSiteData(forum.siteData);
+    server.skipRateLimits(site.id);
+  });
+
+
+  it(`Create embedding pages`, () => {
+    const dir = 'target';
+    fs.writeFileSync(`${dir}/${embeddingPageSlug}`, makeHtml('aaa', '#800'));
+    function makeHtml(pageName: St, bgColor: St): St {
+      return utils.makeEmbeddedCommentsHtml({
+              pageName, discussionId: '', localHostname, bgColor});
+    }
+  });
+
+
+  it(`Mons goes to a dangerous embedding page (in browser A)
+            with an xss attack that lets Mallory steal session part 1 and 2`, async () => {
+    await mons_brA.go2(embeddingPageUrl);
+  });
+
+  it(`... Mons logs in`, async () => {
+    await mons_brA.complex.loginIfNeededViaMetabar(mons);
+  });
+
+  it(`Mallory steals the session via the xss attack`, async () => {
+    monsLocalStorageSessionJsonSt = await mons_brA.execute(function() {
+      return localStorage.getItem('talkyardSession');
+    });
+
+    stolenSidPart1And2WasMons = JSON.parse(monsLocalStorageSessionJsonSt).weakSessionId;
+
+    logMessage(`Mallory stole Mons' session: "${monsLocalStorageSessionJsonSt}",
+        part 1 + 2: "${stolenSidPart1And2WasMons}"`);
+  });
+
+
+  it(`Mallory goes to the embedding page, in his own browser B`, async () => {
+    await mallory_brB.go2(embeddingPageUrl);
+  });
+
+  it(`... puts the stolen session in localStorage`, async () => {
+    await mallory_brB.execute(function(s) {
+      localStorage.setItem('talkyardSession', s);
+    }, monsLocalStorageSessionJsonSt);
+  });
+
+  it(`... reloads`, async () => {
+    await mallory_brB.refresh2();
+  });
+
+  it(`... now Mallory is logged in as Mons — but with session id parts 1+2 only`, async () => {
+    await mallory_brB.switchToEmbeddedCommentsIrame();
+    assert.eq(await mallory_brB.metabar.getMyUsernameInclAt(), '@' + mons.username);
+  });
+
+  it(`Bad 1, will work: Mallory posts a comment`, async () => {
+    await mallory_brB.complex.replyToEmbeddingBlogPost(
+            "Hi I'm Mons no I'm not yes_no_yes_you_guess");
+  });
+
+  let resp;
+
+  it(`Bad 2, won't work: Mallory tries to suspend Maja`, async () => {
+    await mallory_brB.switchToEmbCommentsIframeIfNeeded(); // reidr
+    resp = await mallory_brB.hackServer.suspendUser(majasUserId);
+  });
+  it(`Bad 2: ... the server refuses`, async () => {
+    assert.not(resp.serverSaysOk);  // ttt
+    assert.that(resp.serverSaysError);
+  });
+  it(`Bad 2: ... there's an error dialog`, async () => {
+    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
+    await mallory_brB.serverErrorDialog.close();
+  });
+
+  it(`Bad 3: Mallory then tries to add his own email addr to Mon's account`, async () => {
+    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
+    resp = await mallory_brB.hackServer.addEmailAddresses("mallory@x.co");
+  });
+  it(`Bad 3: ... the server refuses`, async () => {
+    assert.not(resp.serverSaysOk);  // ttt
+    assert.that(resp.serverSaysError);
+  });
+  it(`Bad 3: ... there's an error dialog`, async () => {
+    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
+    await mallory_brB.serverErrorDialog.close();
+  });
+
+
+  it(`Bad 4: Mallory tries to list Mons' email addresses`, async () => {
+    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
+    resp = await mallory_brB.hackServer.loadEmailAddressesAndLoginMethods();
+  });
+  it(`Bad 4: ... the server refuses`, async () => {
+    assert.not(resp.serverSaysOk);  // ttt
+    assert.that(resp.serverSaysError);
+  });
+  it(`Bad 4: ... there's another error dialog`, async () => {
+    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
+    await mallory_brB.serverErrorDialog.close();
+  });
+
+
+  it(`Good 4: Mons also cannot list his email addresses from inside the iframe`, async () => {
+    await mons_brA.switchToEmbCommentsIframeIfNeeded();  // reidr
+    resp = await mons_brA.hackServer.loadEmailAddressesAndLoginMethods();
+  });
+  it(`Good 4: ... the server says no to Mons too`, async () => {
+    assert.not(resp.serverSaysOk);  // ttt
+    assert.that(resp.serverSaysError);
+  });
+  it(`Good 4: ... Mons also gets an error dialog`, async () => {
+    await mons_brA.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
+    await mons_brA.serverErrorDialog.close();
+  });
+
+
+  /*
+  it(`Bad 5: Mallory tries to download Mons' personal data  TESTS_MISSING`, async () => {
+    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
+    resp = await mallory_brB.hackServer.  ?? ();
+  });
+  it(`Bad 5: ... the server refuses`, async () => {
+    assert.not(resp.serverSaysOk);  // ttt
+    assert.that(resp.serverSaysError);
+  });
+  it(`Bad 5: ... there's another error dialog`, async () => {
+    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
+    await mallory_brB.serverErrorDialog.close();
+  });
+  */
+
+  let monsUserId: Nr | U;
+
+  it(`Mallory remembers Mons' user id`, async () => {
+    monsUserId = await mallory_brB.hackServer.getMyUserId();
+    logBoring(`Mons' user id is: ${monsUserId}`);
+  });
+
+
+
+  it(`Mallory click-opens Mons' profile in a new tab`, async () => {
+    await mallory_brB.metabar.openMyProfilePageInNewTab();
+  });
+
+  it(`... but in the new tab, he's not logged in — cookies missing`, async () => {
+    await mallory_brB.swithToOtherTabOrWindow();
+    await mallory_brB.me.waitUntilKnowsNotLoggedIn();
+  });
+
+
+  it(`Mons, though, can click his name ...`, async () => {
+    await mons_brA.switchToEmbCommentsIframeIfNeeded();
+    await mons_brA.metabar.openMyProfilePageInNewTab();
+  });
+
+  it(`... and in the new tab, he *is* logged in`, async () => {
+    await mons_brA.swithToOtherTabOrWindow();
+    await mons_brA.me.waitUntilLoggedIn();
+    await mons_brA.topbar.assertMyUsernameMatches(mons.username);
+  });
+
+  it(`Mons checks his email addresses`, async () => {
+    await mons_brA.userProfilePage.tabs.switchToPreferences();
+    await mons_brA.userProfilePage.preferences.tabs.switchToAccount();
+  });
+
+  it(`... no new address has been added`, async () => {
+    const adrs: St[] =
+            await mons_brA.userProfilePage.preferences.emailsLogins.getAllEmailAddresses();
+    assert.deepEq(adrs, [mons.emailAddress]);
+  });
+
+
+  it(`Mallory uses the stolen session parts 1+2, in the new tab`, async () => {
+    await mallory_brB.hackServer.setSidPart12ViaJs(stolenSidPart1And2WasMons, { inCookie: true });
+  });
+
+  it(`... now Mallory has a cookie for session parts 1+2`, async () => {
+    const sids = await mallory_brB.hackServer.getSids();
+    logMessage(`getSids() says: ${j2s(sids)}`);
+    assert.greaterThan(sids.sidCookiePart123.length, 30);
+  });
+
+  it(`Mallory reloads, tries to use the parts 1+2 cookie`, async () => {
+    await mallory_brB.refresh2({ isWhere: IsWhere.External });
+  });
+  it(`... but there's an error: SID part 3 missing`, async () => {
+    const source = await mallory_brB.getPageSource();
+    assert.includes(source, weakSidErrCode);
+  });
+
+
+  let monsDataUrl: St | U;
+  let monsContentUrl: St | U;
+
+  it(`Mallory constructs links to Mons' personal data and Mons' posts`, async () => {
+    monsDataUrl = mallory_brB.user.genDownloadPersonalDataUrl(monsUserId);
+    monsContentUrl = mallory_brB.user.genDownloadPersonalContentUrl(monsUserId);
+  });
+
+  it(`Mallory then tries to fetch Mons' personal data`, async () => {
+    await mallory_brB.go2(monsDataUrl, { willBeWhere: IsWhere.External });
+  });
+  it(`... this not allowed — session id part 3 missing`, async () => {
+    const source = await mallory_brB.getPageSource();
+    assert.includes(source, weakSidErrCode);
+  });
+
+  it(`Mons, though, can download his data — he has SID part 3 HttpOnly`, async () => {
+    await mons_brA.go2(monsDataUrl, { willBeWhere: IsWhere.External });
+  });
+  it(`... his name and emai (among other things) are in the JSON`, async () => {
+    const source = await mons_brA.getPageSource();
+    assert.includes(source, mons.username);
+    assert.includes(source, mons.emailAddress);
+    assert.excludes(source, weakSidErrCode);
+  });
+
+
+  it(`Mallory tries to fetch Mon's posts`, async () => {
+    await mallory_brB.go2(monsContentUrl, { willBeWhere: IsWhere.External });
+  });
+  it(`... also not allowed — session id part 3 missing`, async () => {
+    const source = await mallory_brB.getPageSource();
+    assert.includes(source, weakSidErrCode);
+  });
+
+  it(`Mons can download his posts — he has SID part 3 HttpOnly`, async () => {
+    await mons_brA.go2(monsContentUrl, { willBeWhere: IsWhere.External });
+  });
+  it(`... Mallory's post, made with the stolen SID part 1+2, is in the JSON`, async () => {
+    const source = await mons_brA.getPageSource();
+    assert.includes(source, 'yes_no_yes_you_guess');
+    assert.excludes(source, weakSidErrCode);
+  });
+
+
+  /* Maybe?
+  it(`... but when Mallory tries to use the sid, the server clears all session cookies
+            — it notices that part 3, HttpOnly, is missing`, async () => {
+  });
+
+  it(`The server also ends the session — since something is amiss`, async () => {
+  });
+  */
+
+});
+
diff --git a/tests/e2e-wdio7/test-types.ts b/tests/e2e-wdio7/test-types.ts
index 2183f5567..22062964c 100644
--- a/tests/e2e-wdio7/test-types.ts
+++ b/tests/e2e-wdio7/test-types.ts
@@ -17,10 +17,11 @@ declare global {
   const allWdioBrowsers: WebdriverIOAsync.MultiRemoteBrowser; // ... started
   const oneWdioBrowser: WebdriverIOAsync.MultiRemoteBrowser;
   const wdioBrowserA: WebdriverIOAsync.MultiRemoteBrowser;
   const wdioBrowserB: WebdriverIOAsync.MultiRemoteBrowser | U;
   const wdioBrowserC: WebdriverIOAsync.MultiRemoteBrowser | U;
+  const wdioBrowserD: WebdriverIOAsync.MultiRemoteBrowser | U;
 
   type Sel = St // selector
   type SelOrEl = St | WebdriverIO.Element;
 
 }
@@ -30,11 +31,11 @@ export const enum IsWhere {
   Forum = 1,
   LoginPopup = 2,
 
   EmbFirst = 3,
   EmbeddingPage = 3,
-  EmbCommentsIframe = 4,
+  EmbCommentsIframe = 4,  // hmm could add an enum value for EmbSessionIframe
   EmbEditorIframe = 5,
   EmbLast = 5,
 
   // E.g. a blog post index page, with <a href=...> to blog posts with emb comments.
   EmbeddedPagesListPage = 6,
diff --git a/tests/e2e-wdio7/test-types2.ts b/tests/e2e-wdio7/test-types2.ts
index d2d8e9499..8954422aa 100644
--- a/tests/e2e-wdio7/test-types2.ts
+++ b/tests/e2e-wdio7/test-types2.ts
@@ -804,8 +804,18 @@ interface EmailMatchResult {
   matchingString?: string;
   matchingStrings: string[];
 }
 
 
+export type ServerSays = ServerSaysOk | ServerSaysError;
+
+export interface ServerSaysOk {
+  serverSaysOk: A;
+}
+
+export interface  ServerSaysError {
+  serverSaysError: A;
+}
+
 // Right now, constraints like >= 1 aren't supported in Typescript, but this works, and, in test
 // cases, probably won't ever need larger numbers?
 type IntAtLeastOne = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19;
diff --git a/tests/e2e-wdio7/utils/settings-exp-def.ts b/tests/e2e-wdio7/utils/settings-exp-def.ts
index e680cc056..1a4fc9e68 100644
--- a/tests/e2e-wdio7/utils/settings-exp-def.ts
+++ b/tests/e2e-wdio7/utils/settings-exp-def.ts
@@ -94,10 +94,16 @@ if (args['2br'] || args['2browsers']) {
   settings.numBrowsers = 2;
 }
 if (args['3br'] || args['3browsers']) {
   settings.numBrowsers = 3;
 }
+if (args['4br'] || args['4browsers']) {
+  settings.numBrowsers = 4;
+}
+if (args['5br'] || args['5browsers'] || args['6br'] || args['6browsers']) {
+  die(`E2e tests with 5 or 6 browsers isn't supported. [TyE2MNYBROWSRS]`);
+}
 
 if (args.cd || args.chromedrier) {
   settings.useChromedriver = true;
 }
 
diff --git a/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts b/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts
index 6e93b9325..3d9678695 100644
--- a/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts
+++ b/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts
@@ -1,7 +1,8 @@
 import * as _ from 'lodash';
 import { IsWhere, isWhere_isInIframe } from '../test-types';
+import { ServerSays } from '../test-types2';
 import { SiteType, NewSiteOwnerType } from '../test-constants';
 
 
 // Why are many WebdriverIO's functions reimplemented here?
 //
@@ -128,10 +129,20 @@ interface WaitAndClickPs extends WaitPs {
 
 
 type WaitForClickableResult = 'Clickable' | 'NotClickable';
 type ClickResult = 'Clicked' | 'CouldNotClick';
 
+interface SidFieldsParts {
+  sidFieldParts12Maybe3?: St;
+}
+
+interface SidParts extends SidFieldsParts {
+  sidCookiePart123?: St;
+  sidCookiePart4?: St;
+  sidCookiePart5?: St;
+}
+
 
 function isBlank(x: string): boolean {
   return _.isEmpty(x) || !x.trim();
 }
 
@@ -265,11 +276,12 @@ export type TyAllE2eTestBrowsers = TyE2eTestBrowser;
 export class TyE2eTestBrowser {
 
   #br: WebdriverIOAsync.Browser;
   #name: St;
 
-  constructor(aWdioBrowser: WebdriverIOAsync.Browser, name: 'brA' | 'brB' | 'brC' | 'brAll') {
+  constructor(aWdioBrowser: WebdriverIOAsync.Browser,
+          name: 'brA' | 'brB' | 'brC' | 'brD' | 'brAll') {
     dieIf(!aWdioBrowser?.getPageSource,
         `Error creating Wdio browser '${name}', this is not a browser: ${
                 JSON.stringify(aWdioBrowser)}  [TyE2E7J02SAD35]`
         + (name === 'brAll' ? '' :
             "\n\nTo use more than one browser, be sure to include '2br' or '3br' etc " +
@@ -408,11 +420,11 @@ export class TyE2eTestBrowser {
 
     async execute<T>(script: ((...args: any[]) => T), ...args: any[]): Pr<T> {
       return await this.#br.execute.apply(this.#br, arguments);
     }
 
-    async executeAsync<T>(script: ((...args: any[]) => T), ...args: any[]): Pr<T> {
+    async executeAsync<T>(script: ((...args: any[]) => V), ...args: any[]): Pr<T> {
       return await this.#br.executeAsync.apply(this.#br, arguments);
     }
 
     async refresh() {
       await this.#br.refresh();
@@ -766,10 +778,20 @@ export class TyE2eTestBrowser {
           message: `Waiting for theStore.me  TyT6503MES63Z`
         }) as TestMyself;
       },
     }
 
+    user = {
+      genDownloadPersonalDataUrl: (userId: PatId): St => {
+        return '/-/download-personal-data?userId=' + userId;
+      },
+
+      genDownloadPersonalContentUrl: (userId: PatId): St => {
+        return '/-/download-my-content?authorId=' + userId;
+      },
+    }
+
 
 
     newSite = {
       createNewSite: async (data: NewSiteData): Pr<NewSiteResult> => {
         // Dupl code [502SKHFSKN53]
@@ -1187,22 +1209,26 @@ export class TyE2eTestBrowser {
       }
     }
 
 
     async switchToEmbeddedCommentsIrame(ps: {
-            waitForContent?: false, discId?: St, theresOnlyOne?: true } = {}) {
+            waitForContent?: false, discId?: St, theresOnlyOne?: true,
+            theSessionIdIframe?: true } = {}) {
       if (ps.discId) {
         this.useCommentsIframe({ discussionId: ps.discId });
       }
       else if (ps.theresOnlyOne) {
         this.useFirstCommentsIframe();
       }
       await this.switchToAnyParentFrame();
       // Let's wait for the editor iframe, so Reply buttons etc will work.
       await this.waitForExist('iframe#ed-embedded-editor');
       let commentsIframeSelector = '';
-      if (this.#useCommentsIframe?.discussionId) {
+      if (ps.theSessionIdIframe) {
+        commentsIframeSelector = 'iframe#talkyard-session';
+      }
+      else if (this.#useCommentsIframe?.discussionId) {
         commentsIframeSelector =
                 `.talkyard-comments[data-discussion-id="${
                         this.#useCommentsIframe.discussionId}"] iframe`;
       }
       else {
@@ -1730,12 +1756,12 @@ export class TyE2eTestBrowser {
     async waitAndClickFirst(selector: St, opts: WaitAndClickPs = {}): Pr<ClickResult> {
       return await this._waitAndClickImpl(selector, { ...opts, clickFirst: true });
     }
 
 
-    async waitAndClickLast(selector: St): Pr<ClickResult> {
-      return await this.waitAndClickNth(selector, -1);
+    async waitAndClickLast(selector: St, ps: { needScroll?: Bo } = {}): Pr<ClickResult> {
+      return await this.waitAndClickNth(selector, -1, ps);
     }
 
 
     // Clicks or throws an error — unless timeoutIsFine, then
     // returns 'CouldNotClick' instead of throwing error.
@@ -1776,21 +1802,22 @@ export class TyE2eTestBrowser {
       }
     }
 
     // For one this.#br at a time only.
     // n starts on 1 not 0. -1 clicks the last, -2 the last but one etc.
-    async waitAndClickNth(selector: string, n: number): Pr<ClickResult> {   // BUG will only scroll the 1st elem into view [05YKTDTH4]
-      dieIf(n <= 0, "n starts on 1, change from 0 to 1 please");
-      logWarningIf(n !== 1,
+    async waitAndClickNth(selector: St, n: Nr, ps: { needScroll?: Bo } = {}): Pr<ClickResult> {   // BUG will only scroll the 1st elem into view [05YKTDTH4]
+      dieIf(n === 0, "n starts on 1, change from 0 to 1 please (or -1 to click the last item)");
+      logWarningIf(n !== 1 && ps.needScroll !== false,
           `n = ${n} !== 1, won't scroll into view before trying to click, maybe will miss:  ${selector} [05YKTDTH4]`);
 
       // Currently always throws if couldn't click — timeoutIsFine isn't set.
       if (await this._waitForClickable(selector) !== 'Clickable')
         return 'CouldNotClick';
 
       const elems = await this.$$(selector);
-      tyAssert.ok(elems.length >= n, `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
+      tyAssert.ok(elems.length >= Math.abs(n),
+            `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
       const index = n > 0
           ? n - 1
           : elems.length - (-n); // count from the end
 
       const elemToClick = elems[index];
@@ -7157,20 +7184,39 @@ export class TyE2eTestBrowser {
         await this.waitAndClick('#e2eUP_PrefsB');
         await this.waitForVisible('.e_UP_Prefs_FN');
         await this.waitUntilLoadingOverlayGone();
       },
 
-      switchToInvites: async () => {
+      switchToInvites: async (ps: { willFail?: true } = {}) => {
         await this.waitAndClick('.e_InvTabB');
-        await this.invitedUsersList.waitUntilLoaded();
+        if (!ps.willFail) {
+          await this.invitedUsersList.waitUntilLoaded();
+        }
       },
 
       waitForTabsVisible: async () => {
         // The activity tab is always visible, if the notfs tab can possibly be visible.
         await this.waitForVisible('.e_UP_ActivityB');
       },
 
+      tabs: {
+        switchToDraftsEtc: async (ps: { willFail?: true } = {}) => {
+          await this.waitAndClick('.e_UP_DrftsB');
+          if (!ps.willFail) {
+            await this.waitUntilLoadingOverlayGone();
+            await this.userProfilePage.draftsEtc.waitUntilLoaded();
+          }
+        },
+
+        isPreferencesTabDisplayed: async (): Pr<Bo> => {
+          return await this.isDisplayed('#e2eUP_PrefsB');
+        },
+        switchToPreferences: async () => {
+          await this.userProfilePage.clickGoToPreferences();
+        },
+      },
+
       isInvitesTabVisible: async (): Pr<Bo> => {
         await this.userProfilePage.waitForTabsVisible();
         return await this.isVisible('.e_InvTabB');
       },
 
@@ -7484,12 +7530,37 @@ export class TyE2eTestBrowser {
       preferences: {  // RENAME to prefs
         goHere: async (username: St, ps: { isGroup?: true, origin?: St } = {}) => {
           await this.userProfilePage._goHere(username, ps, '/preferences');
         },
 
-        switchToEmailsLogins: async () => {  // RENAME to tabToAccount
+        tabs: {
+          waitForAboutTabDisplayed: async (): Pr<Bo> => {
+            return await this.waitForDisplayed('.s_UP_Prf_Nav_AbtL');
+          },
+          switchToAbout: async (ps: { willFail?: true } = {}) => {
+            await this.waitAndClick('.s_UP_Prf_Nav_AbtL');
+            if (!ps.willFail) {
+              await this.waitForVisible('.e_UP_Prefs_FN');
+            }
+          },
+
+          switchToAccount: async (ps: { /*willFail?: true*/ } = {}) => {
+            await this.userProfilePage.preferences.switchToEmailsLogins();
+          },
+
+          isSecurityTabDisplayed: async (): Pr<Bo> => {
+            return await this.isDisplayed('.e_UP_Prf_Nav_SecL');
+          },
+          switchToSecurity: async () => {
+            await this.waitAndClick('.e_UP_Prf_Nav_SecL');
+            await this.userProfilePage.preferences.security.waitForLoaded();
+          },
+        },
+
+        switchToEmailsLogins: async () => {  // MOVE to tabs.switchToAccount() instead (above)
           await this.waitAndClick('.s_UP_Prf_Nav_EmLgL');
+          // Break out fn:  waitForLoaded();  ?
           if ((await this.urlPath()).startsWith(c.UsersUrlPrefix)) {
             // Wait for user emails loaded.
             await this.waitForVisible('.s_UP_EmLg_EmL');
           }
           else {
@@ -7590,19 +7661,79 @@ export class TyE2eTestBrowser {
             await this.waitAndClick('.e_SavePrivacy');
             await this.waitForVisible('.e_Saved');
           },
         },
 
+        security: {
+          goHere: async (viewWho: St | Member, ps: { isGroup?: true, origin?: St,
+                    loginAs?: Member } = {}) => {
+            const username = _.isString(viewWho) ? viewWho : viewWho.username;
+            await this.userProfilePage._goHere(username, ps, '/preferences/security');
+            if (ps.loginAs) {
+              await this.complex.loginWithPasswordViaTopbar(ps.loginAs);
+            }
+          },
+
+          waitForLoaded: async (): Pr<V> => {
+            await this.waitForVisible('.c_SessL');  // or waitForExist? works if not logged in
+          },
+
+          waitForSessions: async (ps: { numActive: Nr, numEnded: Nr }): Pr<V> => {
+            let numNow;
+            await this.waitUntil(async () => {
+              numNow = await this.userProfilePage.preferences.security.countSessions();
+              return _.isEqual(numNow, ps);
+            }, {
+              message: () => `Waiting for ${j2s(ps)} sessions, currently: ${j2s(numNow)}`,
+            })
+          },
+
+          countSessions: async (): Pr<{ numActive: Nr, numEnded: Nr }> => {
+            const numEnded = await this.count('.c_SessL_Sess-Ended');
+            const numActive = await this.count('.c_SessL_Sess-Active');
+            return { numEnded, numActive };
+          },
+
+          numEndSessionButtonsVisible: async (): Pr<Nr> => {
+            const num = await this.count('.c_SessL_Sess_EndB');
+            return num + await this.count('.c_SessL_EndAllB');
+          },
+
+          terminateNewestSession: async (): Pr<V> => {
+            await this.waitAndClickFirst('.c_SessL_Sess_EndB');
+          },
+
+          terminateOldestSession: async (): Pr<V> => {
+            await this.scrollToBottom();
+            await this.waitAndClickLast('.c_SessL_Sess_EndB', { needScroll: false });
+          },
+
+          terminateAllSessions: async (): Pr<V> => {
+            await this.waitAndClick('.c_SessL_EndAllB');
+          },
+        },
+
         emailsLogins: {   // RENAME to `account`
           goHere: async (username: St, ps: { isGroup?: true, origin?: St } = {}) => {
             await this.userProfilePage._goHere(username, ps, '/preferences/account');
           },
 
           getEmailAddress: async (): Pr<St> => {
             return await this.waitAndGetVisibleText('.s_UP_EmLg_EmL_It_Em');
           },
 
+          getAllEmailAddresses: async (): Pr<St[]> => {
+            await this.waitForDisplayed('.s_UP_EmLg_EmL_It_Em');
+            const elms = await this.$$('.s_UP_EmLg_EmL_It_Em');
+            const adrs = [];
+            for (const el of elms) {
+              const text = await el.getText();
+              adrs.push(text);
+            }
+            return adrs;
+          },
+
           waitUntilEmailAddressListed: async (addrRegexStr: St,
                   opts: { shallBeVerified?: Bo } = {}) => {
             const verified = opts.shallBeVerified ? '.e_EmVerfd' : (
               opts.shallBeVerified === false ? '.e_EmNotVerfd' : '');
             await this.waitUntilTextMatches('.s_UP_EmLg_EmL_It_Em' + verified, addrRegexStr);
@@ -9661,6 +9792,170 @@ export class TyE2eTestBrowser {
       removeNthItem: async (n: Nr) => {
         await this.waitAndClickNth(`${selector} .Select-value-icon`, n);
       },
     }},
   }
+
+
+  /// For hacking into Talkyard, doing weird things.
+  ///
+  hackServer = {
+    setSidPart12ViaJs: async (value: St, ps: { inCookie?: true, inField?: true }): Pr<V> => {
+      await this.execute(function(value, ps) {
+        if (ps.inCookie) {
+          console.debug(`Setting cookie TyCoSid123 = "${value}" ...`);
+          window['getSetCookie']('TyCoSid123', value);
+          console.debug(`Done.`);
+        }
+        if (ps.inField) {
+          try {
+            console.debug(`Setting getMainWin().typs.weakSessionId = "${value}" ...`);
+            window['debiki2'].getMainWin().typs.weakSessionId = value;
+            console.debug(`Done.`);
+          }
+          catch (ex) {
+            console.error(ex);  // logs nothing?
+          }
+        }
+      }, value, ps);
+    },
+
+
+    getSidPart12Maybe3ViaJs: async (ps: { inCookie?: true, inField?: true })
+          : Pr<SidFieldsParts | U> => {
+      dieIf(!!ps.inCookie == !!ps.inField, "TyE50MEPW24");
+      const anyValue = await this.#br.execute(function(ps) {
+        let result: SidFieldsParts | U;
+        if (ps.inCookie) {
+          die(`Unimpl: Getting cookie TyCoSid123...`);
+          result = {
+            // sidCookiePart123ViaJs: window['getSetCookie']('TyCoSid123'),
+          };
+          console.debug(`Done.`);
+        }
+        if (ps.inField) {
+          try {
+            console.debug(`Getting getMainWin().typs.weakSessionId...`);
+            result = {
+              sidFieldParts12Maybe3: window['debiki2'].getMainWin().typs.weakSessionId,
+            };
+            console.debug(`Done.`);
+          }
+          catch (ex) {
+            console.error(ex);  // logs nothing?
+          }
+        }
+        console.debug(`Returning: "${result}`);
+        return result;
+      }, ps);
+      return anyValue;
+    },
+
+    getSids: async (): Pr<SidParts> => {
+      const result: SidParts = await this.hackServer.getSidPart12Maybe3ViaJs({ inField: true });
+      const sidCookies = await this.#br.getCookies(
+              ['TyCoSid123', 'TyCoSid4', 'TyCoSid5']);
+      for (const cookie of sidCookies) {
+        const resultFieldName =
+                cookie.name === 'TyCoSid123' ? 'sidCookiePart123' : (
+                cookie.name === 'TyCoSid4' ? 'sidCookiePart4' : (
+                cookie.name === 'TyCoSid5' ? 'sidCookiePart5' : die('TyE503MEGE2')));
+        result[resultFieldName] = cookie.value;
+      }
+      return result;
+    },
+
+    getMyUserId: async (): Pr<PatId> => {
+      return await this.execute(function() {
+        const store = window['theStore'];
+        return store.me.id;
+      });
+    },
+
+    loadEmailAddressesAndLoginMethods: async (): Pr<ServerSays> => {
+      const result: ServerSays = await this.executeAsync<ServerSays>(function(done) {
+        const store = window['theStore'];
+        const me = store.me;
+        // Calls:  `/-/load-email-addrs-login-methods?userId=${userId}`
+        window['debiki2'].Server.loadEmailAddressesAndLoginMethods(me.id, function(resp) {
+          console.debug(`OK response: ${JSON.stringify(resp)}`);
+          done({ serverSaysOk: resp });
+        }, function(resp) {
+          console.debug(`Error response: ${JSON.stringify(resp)}`);
+          done({ serverSaysError: resp });
+        });
+      });
+      console.debug(`Result: ${j2s(result)}`);
+      return result;
+    },
+
+    addEmailAddresses: async (newAdr: St): Pr<ServerSays> => {
+      const result: ServerSays = await this.executeAsync<ServerSays>(function(newAdr, done) {
+        const store = window['theStore'];
+        const me = store.me;
+        // Calls:  /-/add-email-address
+        // handled by:  UserController.addUserEmail().
+        window['debiki2'].Server.addEmailAddresses(me.id, newAdr, function(resp) {
+          console.debug(`OK response: ${JSON.stringify(resp)}`);
+          done({ serverSaysOk: resp });
+        }, function(resp) {
+          console.debug(`Error response: ${JSON.stringify(resp)}`);
+          done({ serverSaysError: resp });
+        });
+      }, newAdr);
+      console.debug(`Result: ${j2s(result)}`);
+      return result;
+    },
+
+    /* Currently not needed, maybe later?:
+
+    tryChangeMyEmail: async (newAdr: St): Pr<{ emailAddresses } | { error }> => {
+      const resp: A = await this.executeAsync(function(newAdr, done) {
+        const store = window['theStore'];
+        const me = store.me;
+        // Calls: /-/set-primary-email-address
+        // handled by: UserController.setPrimaryEmailAddresses().
+        window['debiki2'].Server.setPrimaryEmailAddresses(me.id, newAdr, function(resp) {
+          console.debug(`Emails after: ${JSON.stringify(resp)}`);
+          done(resp);
+        });
+      }, newAdr);
+      console.debug(`Emails after: ${JSON.stringify(resp)}`);
+      return resp;
+    },
+
+    tryChangeMyPrefs: async (newAdr: St): Pr<V> => {
+      const meAfter = await this.executeAsync(function(newAdr, done) {
+        const store = window['theStore'];
+        const me = store.me;
+        window['debiki2'].Server.saveAboutPatPrefs({
+          userId: me.id,
+          emailAddress: newAdr,
+          username: me.username,
+          emailPref: 5, // ReceiveAlways  (whatever)
+        }, false, // isGroup
+        function(patAfter) { // onOk
+          done(patAfter);
+        });
+      }, newAdr);
+    }, */
+
+    suspendUser: async (userId: PatId): Pr<ServerSays> => {
+      const result: ServerSays = await this.executeAsync(function(userId, done) {
+        // Calls:  /-/suspend-user
+        // handled by:  UserController.suspendUser().
+        window['debiki2'].Server.suspendUser(userId, 123, "No reason",
+              function onOk(resp) {
+                console.debug(`OK response: ${JSON.stringify(resp)}`);
+                done({ serverSaysOk: resp });
+              },
+              function onErr(resp) {
+                console.debug(`Error response: ${JSON.stringify(resp)}`);
+                done({ serverSaysError: resp });
+              });
+      }, userId);
+      console.debug(`Result: ${j2s(result)}`);
+      return result;
+    },
+  };
+
 }
diff --git a/tests/e2e-wdio7/wdio.conf.ts b/tests/e2e-wdio7/wdio.conf.ts
index 4d200f5a0..5efab60fe 100644
--- a/tests/e2e-wdio7/wdio.conf.ts
+++ b/tests/e2e-wdio7/wdio.conf.ts
@@ -738,15 +738,17 @@ const browserName = config.capabilities[0].browserName;
 
 // onlyAndSpec won't work with files from stdin unfortunately. (USESTNGS)
 // 2br and 3br is how many browsers the tests need — 2 or 3 browsers, default 1.
 const onlyAndSpec = (settings.only || '') + ((settings as any).spec || '');
 const needsNumBrowsers =
+    onlyAndSpec.indexOf('4br') >= 0 || settings.numBrowsers >= 4
+        ? 4 : (
     onlyAndSpec.indexOf('3br') >= 0 || settings.numBrowsers >= 3
-        ? 3
-        : (onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
-            ? 2
-            : 1);
+        ? 3 : (
+    onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
+        ? 2
+        : 1));
 
 if (needsNumBrowsers >= 2) {
   const theCaps = config.capabilities[0];
 
   config.capabilities = {
@@ -765,10 +767,16 @@ if (needsNumBrowsers >= 2) {
     config.capabilities.browserC = {
       capabilities: { ...theCaps }
     };
   };
 
+  if (needsNumBrowsers >= 4) {
+    config.capabilities.wdioBrowserD = {
+      capabilities: { ...theCaps }
+    };
+  };
+
   console.log(`I'll start ${needsNumBrowsers}${maybeInvisible} ${browserName} browsers.`);
 }
 else {
   console.log(`I'll start one${maybeInvisible} ${browserName} browser.`);
 }
diff --git a/tests/e2e/specs/admin-user-suspend.2browsers.test.ts b/tests/e2e/specs/admin-user-suspend.2browsers.test.ts
index 856d8060c..7ed46558e 100644
--- a/tests/e2e/specs/admin-user-suspend.2browsers.test.ts
+++ b/tests/e2e/specs/admin-user-suspend.2browsers.test.ts
@@ -102,10 +102,13 @@ describe("admin-user-suspend [TyT5GKQSG2]", function() {
 
   it("... and cancels the editor", function() {
     mariasBrowser.editor.cancel();
   });
 
+  // TESTS_MISSING: should not get any more live updates via WebSocket  TyTE0MOREWSMSG
+  // Or is that better done in a separate e2e test file?
+
   it("... Now, she cannot login (was logged out by the server)", function() {
     mariasBrowser.refresh();
     mariasBrowser.acceptAnyAlert(2);  // for some reason, up to 2 alerts
     mariasBrowser.complex.loginWithPasswordViaTopbar(maria, { resultInError: true });
     mariasBrowser.loginDialog.waitForAccountSuspendedError();
diff --git a/tests/e2e/specs/sso-test.2browsers.test.ts b/tests/e2e/specs/sso-test.2browsers.test.ts
index 2fe68e298..c531424c8 100644
--- a/tests/e2e/specs/sso-test.2browsers.test.ts
+++ b/tests/e2e/specs/sso-test.2browsers.test.ts
@@ -124,10 +124,15 @@ describe("sso-test  TyT4ABKRW0268", () => {
     owensBrowser.waitForVisible('.e_SsoTstLgdIn');
   });
 
   it("... he logs out", () => {
     owensBrowser.deleteCookie('dwCoSid');
+    // Deleting any session id cookie, logs us out.  Only cookie TyCoSid123
+    // is not-HttpOnly and can be deleted, so let's delete it.
+    // What! This is Webdriver.io, which can delete all cookies. But whatever,
+    // let's delete 123 only, that's enough.
+    owensBrowser.deleteCookie('TyCoSid123');  // TyTESESS123GONE
     owensBrowser.refresh();
   });
 
   it("On the SSO test page, he sees the SSO URL setting, and the variables-replaced version", () => {
     const ssoUrlShown = owensBrowser.waitAndGetVisibleText('.e_SsoSettingsUrl');
diff --git a/translations/en_US/i18n.ts b/translations/en_US/i18n.ts
index f1de19f1d..f716ed725 100644
--- a/translations/en_US/i18n.ts
+++ b/translations/en_US/i18n.ts
@@ -781,10 +781,11 @@ var t_en_US: TalkyardTranslations = t = {
     Preferences: "Preferences",
     Invites: "Invites",
     DraftsEtc: "Drafts etc",
     About: "About",
     Privacy: "Privacy",
+    Security: "Security",
     Account: "Account",
     Interface: "Interface",
 
     // ----- Overview stats
 

Vulnerable code: import play.api.libs.json.{JsNumber, JsObject, JsString}
  def warnDbgDieIf(test: => Bo, errorCode: St, details: St): U = {
  /** Generates a 130 bit string, almost 26 chars long since each char in a 32 chars
    * alphabet has 5 bits (but we use 36 chars here).
  def nextRandomString(): String =
    new java.math.BigInteger(130, _random).toString(36) // 0..9, a..z
  SECURITY; COULD // use SHA-256 instead.
  private def mdSha1 = js.MessageDigest.getInstance("SHA-1") // not thread safe
  def anyWeirdJsObjField(obj: JsObject, maxLength: Int): Option[String] = {
      if (fieldName.isEmpty) return Some("Empty field name")
      if (!fieldName.isOkVariableName) return Some(s"Weird field name: $fieldName")
      if (fieldName.length > maxLength) return Some(s"Too long field name: $fieldName")
          if (s.value.isEmpty) return Some(s"Empty value for field $fieldName")
          if (!s.value.isOkVariableName) return Some(s"Bad value for field $fieldName: $fieldValue")
          if (s.value.length > maxLength) return Some(s"Too long field value, $fieldName: $fieldValue")
          return Some(s"Value of field $fieldName is weird")
) {
  def isTestSite: Bo = id <= Site.MaxTestSiteId
  ) extends SiteIdHostnames with SiteLimitsMultipliers {
  def isTestSite: Bo = id <= MaxTestSiteId

  def logBrowserErrors: Action[JsValue] = PostJsonAction(RateLimits.BrowserError, maxBytes = 10000) {
          "Phoenix birds sleep at my fireplace")
  def skipRateLimitsForThisSite: Action[JsValue] =
        PostJsonAction(RateLimits.BrowserError, maxBytes = 150) { request =>
  def addAdminNotice: Action[JsValue] =
        PostJsonAction(RateLimits.BrowserError, maxBytes = 50) { request =>
import scala.util.Try
  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = MaxPostSize) {
        RateLimits.DraftSomething, maxBytes = MaxPostSize) { request =>
  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = 1000) {
  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething, maxBytes = 1000) {
        pageId: Option[String], postNr: Option[Int]): Action[Unit] =
      GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
    val drafts = requester flatMap { theRequester =>
  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] =
        GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost, maxBytes = MaxPostSize) {
        AsyncGetActionRateLimited(RateLimits.FetchLinkPreview) { request =>
        GetActionRateLimited(RateLimits.ExpensiveGetRequest) { request =>
  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost, maxBytes = 5000) { request =>
  def movePost: Action[JsValue] = StaffPostJsonAction(maxBytes = 300) { request =>
  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost, maxBytes = 2000) { request =>
  private val ImpersonationCookieName = "esCoImp"
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, userId)
      createSessionIdAndXsrfToken(request.siteId, userId)._3
    val logoutCookie =
      if (anyUserId.isEmpty) Seq(DiscardingSessionCookie)
    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookie: _*)
        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
              // Ignore old impersonation cookies, in case they're leaked somehow.
                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
                  val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, oldUserId)
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, newUser.id)
        security.createSessionIdAndXsrfToken(request.siteId, guestUser.id)
  import context.security.DiscardingSessionCookie
  /** Clears login related cookies and OpenID and OpenAuth stuff, unsubscribes
    * from any event channel.
    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath)
  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St]): Result = {
      request.dao.logout(theRequester, bumpLastSeen = true)
    response.discardingCookies(DiscardingSessionCookie)
    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, member.id)
    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
    import request.body
    CLEAN_UP // remove daoFor, use request.dao instead. Just look in the logs that this'll
    // work fine for sure (shouldn't be any "TyEWEIRDDAO" in the logs).
    val dao = daoFor(request.request)
    if (dao.siteId != request.dao.siteId) {
      if (globals.isProd) {
        logger.warn("Weird: dao.siteId != request.dao.siteId  [TyEWEIRDDAO]")
      }
      else {
        die("TyE305AKTFWJ2", "Wrong dao, *harmmless* but why?")
      }
    }

          val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(dao.siteId, newMember.id)
        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, user.id)
  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply, maxBytes = MaxPostSize) {
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
import talkyard.server.p_Result
  def loadUserAnyDetails(who: String): Action[Unit] = GetAction { request =>
      import request.{dao, theRequester => requester}
        "browserIdDataRecentFirst" -> browserIdDataJson)
  def loadAuthorBlocks(postId: Int): Action[Unit] = GetAction { request =>
  def loadMyPageData(pageIds: St): Action[U] = GetAction { request =>
        maxBytes = 1000) { request =>
        maxBytes = 1000) { request =>
  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
        request =>
  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
        request =>
  def snoozeNotifications(): Action[JsValue] =
          PostJsonAction(RateLimits.ConfigUser, 200) { request =>
  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, maxBytes = 500) {
        request =>
  def listAllUsers(usernamePrefix: String): Action[Unit] = GetAction { request =>
  def listUsernames(pageId: PageId, prefix: String): Action[Unit] = GetAction { request =>
    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookie)
import debiki.dao.{UsersOnlineStuff, NoUsersOnlineStuff}
import ed.server.security.EdSecurity
  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits, maxBytes = 2) {
        request =>
        var forbidden = ForbiddenResult(s"TyM0APPR_-$code", message)
        if (logout) forbidden = forbidden.discardingCookies(security.DiscardingSessionCookie)
        return Future.successful(forbidden)
  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost, maxBytes = 500) {
        request: JsonPostRequest =>
  def loadVoters(postId: PostId, voteType: Int): Action[Unit] = GetAction { request =>
      case _ => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject")
      case _ => throwBadJson("TyE0JSARR", s"$what is not a JsArray")
  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[JsArray] =
      case a: JsArray => a
    readOptLong(json, fieldName).orElse(readOptLong(json, altName)) map { valueAsLong =>
      val maxVal = max getOrElse Int.MaxValue
      val minVal = min getOrElse Int.MinValue
      if (valueAsLong > maxVal)
        throwBadJson("TyEJSNGTMX", s"$fieldName too large: $valueAsLong, max is: $maxVal")
      if (valueAsLong < minVal)
        throwBadJson("TyEJSNLTMN", s"$fieldName too small: $valueAsLong, min is: $minVal")
      valueAsLong.toInt
      requestersJsonImpl(requester, pageRequest.pageId, watchbarWithTitles,
      requestersJsonImpl(requester, anyPageId = None, watchbarWithTitles,
  private def requestersJsonImpl(requester: Participant, anyPageId: Option[PageId],
    val user = readWriteTransaction { tx =>
      user
    }
    logout(user.noDetails, bumpLastSeen = false)
  def logout(pat: Pat, bumpLastSeen: Bo): U = {
    readWriteTransaction { tx =>
    removeUserFromMemCache(pat.id)
    val (user, usersGroupIds) = readWriteTransaction { tx =>
    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true).async(f)
  def AsyncGetActionRateLimited(rateLimits: RateLimits)(f: GetRequest => Future[Result])
    PlainApiAction(cc.parsers.empty, rateLimits).async(f)
    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true)(f)
    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = true, avoidCookies = avoidCookies)(f)
  def GetActionRateLimited(rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = allowAnyone)(f)
  def PostJsonAction(rateLimits: RateLimits, maxBytes: Int,
      rateLimits, allowAnyone = allowAnyone, isLogin = isLogin)(f)
  def PostTextAction(rateLimits: RateLimits, maxBytes: Int, allowAnyone: Boolean = false)(
      rateLimits, allowAnyone = allowAnyone)(f)
  def StaffPostJsonAction(maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
      cc.parsers.json(maxLength = maxBytes))(f)
  def originOf(request: DebikiRequest[_]) =
  def originOf(request: RequestHeader) =
  def daoFor(request: Request[_]) = {
    val site = globals.lookupSiteOrThrow(originOf(request))
    globals.siteDao(site.id)
  }


  def maySeePostUseCache(pageId: PageId, postNr: PostNr, user: Option[Participant])
        : (MaySeeOrWhyNot, String) = {
    case SidOk("_api_secret_", 0, _) => true
  def session: mvc.Session = request.session
    siteIdAndCanonicalHostname, sid, xsrfToken, browserId, user, pageExists,
/** Play Framework Actions for requests to Debiki's HTTP API.
  import security.DiscardingSessionCookie
  def PlainApiAction[B](parser: BodyParser[B],
        rateLimits: RateLimits, allowAnyone: Bo = false, isLogin: Bo = false,
        avoidCookies: Bo = false, skipXsrfCheck: Bo = false)
        : ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, rateLimits,
  def PlainApiActionStaffOnly[B](parser: BodyParser[B]): ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, NoRateLimits, staffOnly = true)
    PlainApiActionImpl(parser, rateLimits, viaApiSecretOnly = true)
              SidOk("_api_secret_", 0, Some(SysbotUserId)),
          SidOk("_api_secret_", 0, Some(user.id)), XsrfOk("_api_secret_"), None, block)
      // by Privacy Badger or iOS or browser settings for 3rd-party-cookies.
      // the AvoidCookiesHeaderName header, so we won't froget that we should avoid cookies here.
      val (actualSidStatus, xsrfOk, newCookies) = corsInfo match {
          (SidAbsent, XsrfOk("cors_no_xsrf"), Nil)
        case _: CorsInfo.SameOrigins | CorsInfo.NotCorsNoOriginHeader =>
                request, anyRequestBody = Some(request.body), siteId = site.id,
      val (mendedSidStatus, deleteSidCookie) =
                mendedSidStatus, xsrfOk, anyBrowserId, block)
              .discardingCookies(DiscardingSessionCookie))
        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteSidCookie) {
              .withCookies(newCookies ::: newBrowserIdCookie: _*)
              .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
            if (deleteSidCookie) {
                resultWithCookies.discardingCookies(DiscardingSessionCookie)
          anyUserMaybeSuspended: Option[Participant], sidStatus: SidStatus,
      // in one browser and got logged out there, only.
      if (anyUserMaybeSuspended.exists(_.isDeleted))
          ForbiddenResult("TyEUSRDLD", "That account has been deleted")
            .discardingCookies(DiscardingSessionCookie))
      if (isSuspended && request.method != "GET")
            ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
              .discardingCookies(DiscardingSessionCookie))
        if (isSuspended) None
        else anyUserMaybeSuspended
        site, sidStatus, xsrfOk, browserId, anyUser, dao, request)
        // BUG: (old? can still happen?) We won't get here if e.g. a 403 Forbidden exception
        // was thrown because 'anyUser' was set to None. How solve that?
        result = result.map(_.discardingCookies(DiscardingSessionCookie))(executionContext)
    browserId: Option[BrowserId],
    user: Option[Participant],
    browserId: Option[BrowserId],
    user: Option[Participant],
import p.mvc.{Action, ControllerComponents, RequestHeader, Result}
        request: RequestHeader =>
  private def webSocketImpl(request: RequestHeader): Future[Either[
  private def authenticateWebSocket(site: SiteBrief, request: RequestHeader)
    val (sessionId, xsrfOk, newCookies) =
                request, anyRequestBody = None, siteId = site.id,
    // For now, let's require a browser id cookie — then, *in some cases* simpler
          .discardingCookies(security.DiscardingSessionCookie))
          // + discard browser id co too   *edit:* Why?
          .discardingCookies(security.DiscardingSessionCookie))
    val authnReq = AuthnReqHeaderImpl(site, sessionId, xsrfOk,
 * Copyright (C) 2011-2017 Kaj Magnus Lindberg
import com.debiki.core.isDevOrTest

case class SidExpired(minutesOld: Long, maxAgeMins: Long) extends SidStatus
        siteId: SiteId, expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
        : (SidStatus, XsrfOk, List[Cookie]) = {
    val anySessionIdCookieValue: Opt[St] = urlDecodeCookie(SessionIdCookieName, request)
    val anySessionId: Opt[St] =
          anySessionIdCookieValue orElse request.headers.get(SessionIdHeaderName)
    val sessionIdStatus: SidStatus =
          anySessionId.map(
            checkSessionId(siteId, _, now, expireIdleAfterMillis = expireIdleAfterMillis)
            ) getOrElse SidAbsent
    val maybeCredentials = cookies.nonEmpty ||
          // There's also the session id header, SessionIdHeaderName
          // (for embedded discussions, cookies then usually won't work).
          sessionIdStatus != SidAbsent
    val sidXsrfNewCookies: (SidStatus, XsrfOk, List[Cookie]) =

        (sessionIdStatus, xsrfOk, anyNewXsrfCookie)
      else if (isPost && !maybeCredentials) {
        (SidAbsent, XsrfOk("_no_creds_"), Nil)
            checkXsrfToken(
              xsrfToken, anyXsrfCookieValue,
              thereIsASidCookie = anySessionIdCookieValue.isDefined,
              now, expireIdleAfterMillis = expireIdleAfterMillis)
        CLEAN_UP // simplify this weird match-case!  & don't take & "return"
        // sessionIdStatus for no reason all the time.
        val r = sessionIdStatus match {
          case s: SidOk => (s, xsrfOk, Nil)
          case SidAbsent => (SidAbsent, xsrfOk, Nil)
          case s: SidExpired => (s, xsrfOk, Nil)
          case _ =>
                  DiscardingSessionCookie))
        dieIf(isDevOrTest && r != (sessionIdStatus, xsrfOk, Nil), "TyE205RKPG36")
        r
    sidXsrfNewCookies
  def createSessionIdAndXsrfToken(siteId: SiteId, userId: UserId): (SidOk, XsrfOk, List[Cookie]) = {
    COULD_OPTIMIZE // pass settings or a dao to here instead? so won't need to create this 2nd one.
                    // (the caller always already has one)
    val dao = globals.siteDao(siteId)
    // Note that the xsrf token is created using the non-base64 encoded cookie value.
    val sidOk = createSessionId(siteId, userId)
    UX; SECURITY; SHOULD // use HttpOnly cookies — otherwise Safari will delete the cookie
    // after 7 days. See: https://webkit.org/blog/8613/intelligent-tracking-prevention-2-1/
    // the "Client-Side Cookies Capped to 7 Days of Storage", section, and sub section
    // "Will This Change Log Users Out?" — cookies that are Secure and HttpOnly aren't deleted.
    // This means the client can no longer look at the session cookie, to find out if one is
    // logged in? Could add JS variables instead.  [NOCOOKIES]
    val xsrfCookie = urlEncodeCookie(XsrfCookieName, xsrfOk.value,
      maxAgeSecs = Some(expireIdleAfterSecs + XsrfAliveExtraSeconds))
  private val HashLength: Int = 15
  private def secretSalt = globals.applicationSecret
  private def checkSessionId(siteId: SiteId, value: String, now: When, expireIdleAfterMillis: Long)
      : SidStatus = {
    if (value.length <= HashLength) return SidBadFormat
      s"$secretSalt.$siteId$dotUseridDateRandom") take HashLength
    if (hash != realHash) return SidBadHash
    dotUseridDateRandom.drop(1).split('.') match {
            return SidBadFormat
        if (ageMillis > expireIdleAfterMillis)
          return SidExpired(
            minutesOld = ageMillis / MillisPerMinute,
            maxAgeMins = expireIdleAfterMillis / MillisPerMinute)
  private def createSessionId(siteId: SiteId, userId: UserId): SidOk = {
    // For now, create a SID value and *parse* it to get a SidOk.
    // This is stupid and inefficient.
    val uid = "" // for now
      s"$secretSalt.$siteId.$useridDateRandom") take HashLength
    val value = s"$saltedHash.$useridDateRandom"
    checkSessionId(siteId, value, now, expireIdleAfterMillis = Long.MaxValue).asInstanceOf[SidOk]
  def SecureCookie(name: String, value: String, maxAgeSeconds: Option[Int] = None,
        httpOnly: Boolean = false) =
  def DiscardingSessionCookie: DiscardingCookie = DiscardingSecureCookie(SessionIdCookieName)
  //    Set-Cookie: dwCoUserEmail="kajmagnus79%40gmail.com";Path=/
  //    Set-Cookie: dwCoUserName="Kaj%20Magnus";Path=/
  //    Set-Cookie: dwCoUserEmail=kajmagnus79@gmail.com;Path=/
  //    Set-Cookie: dwCoUserName="Kaj Magnus";Path=/
  private def urlEncodeCookie(name: String, value: String, maxAgeSecs: Option[Int] = None) =
      sameSite = anySameSiteCookieValue(),
      httpOnly = false)
      val nestedQueries: Opt[JsArray] = parseOptJsArray(jsVal, "manyQueries")
      val nestedActions: Opt[JsArray] = parseOptJsArray(jsVal, "doActions")
        val (sid, _, sidAndXsrfCookies) = security.createSessionIdAndXsrfToken(siteId, user.id)
            // Not yet weak but later. [weaksid]
          security.createSessionIdAndXsrfToken(siteId, user.id)
      // Not yet weak but later. [weaksid]  [NOCOOKIES]
      "weakSessionId" -> JsString(sid.value)))
// Maybe store this in Myself instead? Next to Myself.mySidPart1ForJs? [sess_in_me]
        isLoggedIn: !!getSetCookie('dwCoSid'),
      const hasSid = getSetCookie('dwCoSid');
    dieIf(!anyMe, 'TyE4032SMH57');
  Server.deleteTempSessId();
export function loadEmailAddressesAndLoginMethods(userId: UserId, success: UserAcctRespHandler) {
  get(`/-/load-email-addrs-login-methods?userId=${userId}`, response => {
    success(response);
  });
export function addEmailAddresses(userId: UserId, emailAddress: string, success: UserAcctRespHandler) {
  postJsonSuccess('/-/add-email-address', success, { userId, emailAddress });
export function suspendUser(userId: UserId, numDays: number, reason: string, success: () => void) {
  postJsonSuccess('/-/suspend-user', success, {
      if (!mainWin.typs.weakSessionId && !getSetCookie('dwCoSid')) {
            `No cookie, no typs.weakSessionId. ` +
  weakSessionId?: St;
  return _.includes(store.siteFeatureFlags, featureFlag) ||
declare function getSetCookie(cookieName: string, value?: string, options?: any): string;
  const currentUser = debiki2.ReactStore.getMe();
  if (currentUser.isLoggedIn) {
      if (currentUser.userId !== parseInt(newUserIdString)) {
// We store a weak session in localStorage, if 3rd party cookies disabled.
// It's fairly ok to use localStorage in our case, see:
//   ../../docs/session-in-local-storage.md
// [NOCOOKIES] [weaksid] ADD_TO_DOCS The session is (will be) "weak", in
// the sense that, even if you're admin, you cannot use it to go to the admin area
// and do things there. Then instead you need to login directly to the Talkyard
// server, rather than on the embedding site via the iframe — so an XSS
// vulnerability on the embedding site (the blog) cannot give admin access.

          theStorage.setItem('talkyardSession', JSON.stringify(item));
  ApiSecret =  // instead of SidOk("_api_secret_", ..)

see: SessionType.AutoTokenSiteCustomSso, and: SidOk("_api_secret_".
Also see: [weaksid].
  const skip2And3Browsers = ['!.2br', '!.3br'];
      it(`Owen remains logged in?  Or needs to login again?`, async () => {
        // Hmm, currently:
  EmbCommentsIframe = 4,
  constructor(aWdioBrowser: WebdriverIOAsync.Browser, name: 'brA' | 'brB' | 'brC' | 'brAll') {
    async executeAsync<T>(script: ((...args: any[]) => T), ...args: any[]): Pr<T> {
            waitForContent?: false, discId?: St, theresOnlyOne?: true } = {}) {
      if (this.#useCommentsIframe?.discussionId) {
    async waitAndClickLast(selector: St): Pr<ClickResult> {
      return await this.waitAndClickNth(selector, -1);
    async waitAndClickNth(selector: string, n: number): Pr<ClickResult> {   // BUG will only scroll the 1st elem into view [05YKTDTH4]
      dieIf(n <= 0, "n starts on 1, change from 0 to 1 please");
      logWarningIf(n !== 1,
      tyAssert.ok(elems.length >= n, `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
      switchToInvites: async () => {
        await this.invitedUsersList.waitUntilLoaded();
        switchToEmailsLogins: async () => {  // RENAME to tabToAccount
        ? 3
        : (onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
            ? 2
            : 1);
Fixed code: import play.api.libs.json._
  def warnDevDieIf(test: => Bo, errorCode: St, details: St = ""): U =
    warnDbgDieIf(test, errorCode, details)

  RENAME // to ..DevDie..?
  def warnDbgDieIf(test: => Bo, errorCode: St, details: St = ""): U = {
  def warnDevDieUnless(test: => Bo, errorCode: St, details: St = ""): U = {
    if (!test) {
      warnDbgDie(errorCode, details)
    }
  }

    warnDevDie(errorCode, warningMsg)
  }

  def warnDevDie(errorCode: St, warningMsg: St = ""): U = {
  /** Generates a by default 130 bits entropy string, almost 26 chars long since
    * each char in a 32 chars alphabet has 5 bits (but we use 36 chars here).
  def nextRandomString(bitsEntropy: i32 = 130, base36: Bo = true,
          base64UrlSafe: Bo = false): St = {
    require(base36 != base64UrlSafe)
    if (base36) {
      // Hmm the resulting length is a bit random — if the BigInteger happens to start
      // with 0, the base 36 encoding will be shorter (leading zeroes are excluded).
      val bigInt = new java.math.BigInteger(bitsEntropy, _random)
      bigInt.toString(36) // 0..9, a..z
    }
    else {
      assert(base64UrlSafe)
      // Divisible by 8, so byte aligned — _random operates on bytes.
      assert(bitsEntropy % 8 == 0)
      // Divisible by 6, so always result in same length Base64 repr.
      assert(bitsEntropy % 6 == 0)
      val numBytes = bitsEntropy / 8
      val bytesArray = new Array[Byte](numBytes)
      _random.nextBytes(bytesArray)
      val res = acb.Base64.encodeBase64URLSafeString(bytesArray)
      assert(res.length * 6 == bitsEntropy)
      res
    }
  }
  SECURITY; COULD_OPTIMIZE // use BLAKE3 instead.
  private def mdSha1: js.MessageDigest = js.MessageDigest.getInstance("SHA-1") // not thread safe

  /// SHA-512/256 is faster and stronger than SHA-224 (not in a way that matters, but anyway).
  /// And BLAKE3 has a 256 bits output too by default.
  private def mdSha512: js.MessageDigest =
    js.MessageDigest.getInstance("SHA-512") // not thread safe
  def hashSha512FirstHalf32Bytes(text: St): Array[i8] = {
    // I think this isn't the real SHA512/256, but this'll be ok too.
    // SHA-512/256 not incl in Java 8.
    // Later: Use BLAKE3, the Rust implementation, call from Java.
    val res = mdSha512.digest(text.getBytes("UTF-8"))
    assert(res.length == 32 * 2)
    res take 32
  }

  private val AlNumDashRegex = "^[a-zA-Z0-9_-]*$".r
  def anyWeirdJsObjField(obj: JsObject, maxLength: i32,
           allowHeaderNameValues: Bo = false): Opt[St] = {
    unimplIf(allowHeaderNameValues, "TyE50MFEDJ4601")
      if (fieldName.isEmpty)
        return Some("Empty field name")

      if (allowHeaderNameValues) {
        if (!fieldName.isOkHeaderName)
          return Some(s"Weird header name: '$fieldName' [TyE0HDRNAME]")
      }
      else {
        if (!fieldName.isOkVariableName)
          return Some(s"Weird field name: '$fieldName' [TyE0VARNAME]")
      }

      if (fieldName.length > maxLength)
        return Some(s"Too long field name: '$fieldName' [TyE2LNGFLDNM]")

          if (s.value.isEmpty)
            return Some(s"Empty value for field $fieldName")

          if (allowHeaderNameValues) {
            // Don't allow newlines in value?
            unimpl("TyE50MFEDJ4602")
          }
          else if (!s.value.isOkVariableName) {
            return Some(s"Bad value for field $fieldName: '$fieldValue'")
          }

          if (s.value.length > maxLength)
            return Some(s"Too long field value, $fieldName: '$fieldValue'")

          return Some(s"Value of field $fieldName is not a nummer or string; it is a ${
            classNameOf(fieldValue)} [TyEFIELDVALTYP]")
  def jsObjectSize(obj: JsObject, depth: i32 = 0): i32 = {
    TESTS_MISSING
    var size = 0
    for ((fieldName, value) <- obj.fields) {
      size += fieldName.length + jsValueSize(value, depth = depth + 1)
    }
    size
  }

  private def jsValueSize(value: JsValue, depth: i32 = 0): i32 = {
    TESTS_MISSING
    if (depth > 10)
      return Int.MaxValue // for now
    value match {
      case JsNull => 4
      case _: JsBoolean => 4
      case _: JsNumber => 4 // let's just guess 4 bytes. Or log-10?
      case s: JsString => s.value.length
      case _: JsArray =>
        // What about infinitely deeply nested empty arrays in arrays! Would be size 0 :-(
        Int.MaxValue // for now
        // a.value.foldLeft(0)((len, v) => {
        //   jsValueSize(v, sizeThisFar + len, maxAllowedSize, depth = depth + 1)
        // })
      case _: JsObject =>
        Int.MaxValue // for now
        //jsObjectSize(o)
      case _ =>
        // What's this?
        assert(false)
        Int.MaxValue
    }
  }

  // Move to where?
  val JsEmptyObj2: JsObject = JsObject(Nil)

  /*
  def mapKeyValuesTotalLength(map: Map[St, Any], depth: i32 = 0): i32 = {
    var size = 0
    for ((fieldName, value) <- map) {
      size += fieldName.length + jsValueSize(value, depth = depth + 1)
      // or:   + value match { case n: some-number: ...  case s: St => s.length  case _ => no! }
    }
    size
  } */
    def isOkHeaderName: Bo =
      underlying.nonEmpty && AlNumDashRegex.pattern.matcher(underlying).matches

trait SiteTrait {
  def id: SiteId
  def pubId: PubSiteId
  def status: SiteStatus
  def featureFlags: St

  def isTestSite: Bo = id <= Site.MaxTestSiteId

  def isFeatureEnabled(ffName: St, serverFeatureFlags: St): Bo = {
    val offName = "0" + ffName  // zero  — same as when disabling options in Vim
    val enabledWholeServer = serverFeatureFlags.contains(ffName)
    val disabledWholeServer = serverFeatureFlags.contains(offName)
    val enabledThisSite = featureFlags.contains(ffName)
    val disabledThisSite = featureFlags.contains(offName)
    val enabledSomewhere = enabledWholeServer || enabledThisSite
    val disabledSomewhere = disabledWholeServer || disabledThisSite
    // By default a feature flag is not enabled, and can be enabled in a   [ff_on_off]
    // specific site only via this.featureFlags. So, if a feature has been
    // disabled explicitly in the whole server, then, that overrides
    // it being enabled per site (so it'll be disabled everywhere) (Otherwise
    // disabledWholeServer would be pointless.)
    // However, if a feature is enabled by default (for all sites), then,
    // a site can disable it.
    enabledSomewhere && !disabledSomewhere
  }
}


) extends SiteTrait {
  ) extends SiteIdHostnames with SiteLimitsMultipliers with SiteTrait {

  def loadSession(part1Maybe2Or3: Opt[St] = None, part4HttpOnly: Opt[St] = None,
        maybeActiveOnly: Bo = false): Opt[TySessionInDbMaybeBad]
  def loadOneOrTwoSessions(part1Maybe2Or3: Opt[St], part4HttpOnly: Opt[St],
        maybeActiveOnly: Bo): ImmSeq[TySessionInDbMaybeBad]
  def loadActiveSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad]
  def insertValidSession(session: TySession): U
  def upsertSession(session: TySessionInDbMaybeBad): U

  def deletePersonalDataFromOldSessions(): U
package com.debiki.core

import com.debiki.core.Prelude._
import play.api.libs.json.JsObject


/** The session id is split into 5 parts in 3 cookies: a not-HttpOnly cookie,
  * a HttpOnly and a HttpOnly SameSite=Strict cookie.  [cookie_theory]
  *
  * Each one of the 5 parts is, entropy wise, strong enough, on its own.
  * To do more "important" things, more parts are needed. Whilst
  * the first parts, not-HttpOnly, make Talkyard work also in blog comments iframes
  * where cookies tend to not work.
  *
  * If expiredAt or deletedAt is set, the session cannot be used.
  * A session can be both expired and deleted (if it gets deleted just when it expires).
  *
  * @param patId
  * @param createdAt
  * @param deletedAt
  * @param expiredAt — updated lazily, on use. So, even if the
  *   current time is past the createdAt + expiration time, expiredAt might be unset
  *   — but when pat tries to use the session again, the server will notice it has
  *   expired, and update expiredAt, thereby terminating the session.
  * @param version
  * @param startIp — where the user was, when hen started the session.
  * @param startBrowserId
  * @param startHeaders — some relevant headers from the request that created the session.
  * @param part1CompId — comparation id, not really secret.
  * @param part2HashForEmbgStorage
  * @param part3HashForDirJs
  * @param part4HashHttpOnly
  * @param part5HashStrict
  */
case class TySessionInDbMaybeBad(
  patId: PatId,
  createdAt: When,
  deletedAt: Opt[When],
  expiredAt: Opt[When],
  version: i32,
  startIp: Opt[IpAdr],
  startBrowserId: Opt[BrowserIdSt],
  startHeaders: JsObject,
  part1CompId: St,
  part2HashForEmbgStorage: Array[i8],
  part3HashForDirJs: Array[i8],
  part4HashHttpOnly: Array[i8],
  part5HashStrict: Array[i8]) {

  def isValidNow(now: When, expireIdleAfterMins: i32): Bo =
    wasValidJustRecently && !expiresNow(now, expireIdleAfterMins = expireIdleAfterMins)

  def wasValidJustRecently: Bo = !isDeleted && !hasExpired

  def isDeleted: Bo = deletedAt.isDefined
  def hasExpired: Bo = expiredAt.isDefined

  def expiresNow(now: When, expireIdleAfterMins: i32): Bo = {
    val expiresAt = createdAt.millis + expireIdleAfterMins * MillisPerMinute
    expiresAt <= now.millis
  }


  /// We lookup the session by part 1, and part 2 is required — so 1 and 2 are never absent.
  def copyAsValid(part2: St, part3: Opt[St], part4: Opt[St], part5: Opt[St])
          : TySession = {
    dieIf(expiredAt.isDefined || deletedAt.isDefined, "TyESESS0VALID")
    TySession(
          patId = patId,
          createdAt = createdAt,
          version = version,
          startIp = startIp,
          startBrowserId = startBrowserId,
          startHeaders = startHeaders,
          part1CompId = part1CompId,
          part2ForEmbgStorage = part2,
          part2Hash = part2HashForEmbgStorage,
          part3ForDirJs = part3,
          part3Hash = part3HashForDirJs,
          part4HttpOnly = part4,
          part4Hash = part4HashHttpOnly,
          part5Strict = part5,
          part5Hash = part5HashStrict)
  }
}


/** Later, more fields, see:
  * https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-content-or-value
  *
  * @param patId
  * @param createdAt
  * @param version
  * @param part1CompId
  * @param part2ForEmbgStorage
  * @param part2Hash
  * @param part3ForDirJs
  * @param part3Hash
  * @param part4HttpOnly
  * @param part4Hash
  * @param part5Strict
  * @param part5Hash
  * @param isApiCall COULD split TySession into ClientSession and SingleApiReqSession maybe?
  */
case class TySession(
  patId: PatId,
  createdAt: When,
  version: i32,
  startIp: Opt[IpAdr],
  startBrowserId: Opt[BrowserIdSt],
  startHeaders: JsObject,
  part1CompId: St,
  part2ForEmbgStorage: St,
  part2Hash: Array[i8],
  part3ForDirJs: Opt[St],
  part3Hash: Array[i8],
  part4HttpOnly: Opt[St],
  part4Hash: Array[i8],
  part5Strict: Opt[St],
  part5Hash: Array[i8],
  isApiCall: Bo = false) {

  import TySession._
  require(version == 1, "TyE30MFEW25MMR")

  require(jsObjectSize(startHeaders) < MaxSessionHeadersSize, "TyE4MW2AP7J")

  require(part1CompId.length  == SidLengthCharsPart1, s"Len pt 1: ${part1CompId.length}")

  require(part2Hash.length == SidHashLengthBytes, s"Len pt 2 hash: ${part2Hash.length}")
  require(part2ForEmbgStorage.length == SidLengthCharsPart2,
        s"Len pt 2: ${part2ForEmbgStorage.length}")

  require(part3Hash.length == SidHashLengthBytes, s"Len pt 3 hash: ${part3Hash.length}")
  require(part3ForDirJs.forall(_.length == SidLengthCharsPart3),
        s"Len pt 3: ${part3ForDirJs.map(_.length)}")

  require(part4Hash.length == SidHashLengthBytes, s"Len pt 4 hash: ${part4Hash.length}")
  require(part4HttpOnly.forall(_.length == SidLengthCharsPart4),
        s"Len pt 4: ${part4HttpOnly.map(_.length)}")

  require(part5Hash.length == SidHashLengthBytes, s"Len pt 5 hash: ${part5Hash.length}")
  require(part5Strict.forall(_.length == SidLengthCharsPart5),
        s"Len pt 5: ${part5Strict.map(_.length)}")

  if (com.debiki.core.isDevOrTest) {
    import com.debiki.core.Prelude.{hashSha512FirstHalf32Bytes => hash}
    dieIf(!hash(part2ForEmbgStorage).sameElements(part2Hash), "TyEBADHASH02")
    dieIf(part3ForDirJs.exists(!hash(_).sameElements(part3Hash)), "TyEBADHASH03")
    dieIf(part4HttpOnly.exists(!hash(_).sameElements(part4Hash)), "TyEBADHASH04")
    dieIf(part5Strict.exists(!hash(_).sameElements(part5Hash)), "TyEBADHASH05")
  }

  def part1And2: St = part1CompId + part2ForEmbgStorage

  def part3Absent: Bo = part3ForDirJs.isEmpty
  def part4Absent: Bo = part4HttpOnly.isEmpty
  def part4Present: Bo = !part4Absent
  def part5Absent: Bo = part5Strict.isEmpty
}



object TySession {

  val CurVersion = 1

  // A random Base64 char is 6 bits entropy (with a good rand gen).
  val SidEntropyPerChar: i32 = 6
  val SidCharsetBase: i32 = 64

  // These long session ids might seem like a bit overkill — OWASP writes that 64 bits
  // entropy is enough, that's about 11 chars in Base64.  [sid_part1]
  // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-entropy
  //
  // However, parts 1, 2 are for embedded comments, and for optionally saving in
  // the localStorage of embedding pages (e.g. blog posts) which is more risky.
  // If indeed parts 1 and 2 get compromised, we can still rely on part 3 alone
  // — it's 144 bits entropy > 64.
  //
  val SidLengthCharsPart1: i32 = 16
  val SidLengthCharsPart2: i32 = 24
  val SidLengthCharsPart3: i32 = 24
  val SidLengthCharsPart4: i32 = 24
  val SidLengthCharsPart5: i32 = 16

  val SidLengthCharsPart12: i32 =
      SidLengthCharsPart1 +
      SidLengthCharsPart2

  val SidLengthCharsPart123: i32 =
      SidLengthCharsPart12 +
      SidLengthCharsPart3

  val SidLengthCharsTotal: i32 =
        SidLengthCharsPart123 +
        SidLengthCharsPart4 +
        SidLengthCharsPart5

  val ApiSecretPart1 = "API_SID_PT_1_pad"          // 16 chars
  val ApiSecretPart2 = "API_SID_PT_2_pad_24_abcd"  // 24 chars
  val ApiSecretPart12: St = ApiSecretPart1 + ApiSecretPart2

  // BLAKE3 and SHA-512/256 have 256 bits output size = 32 bytes.
  val SidHashLengthBytes = 32

  // (Part 3, 4, 5 not included in an API call TySession, so the hashes
  // can be set to whatever. But part 2 is always included; the real hash is needed.)
  val DummyHash: Array[i8] = Array.fill(SidHashLengthBytes)(0.asInstanceOf[i8])
  val DummyHashPart2: Array[i8] = hashSha512FirstHalf32Bytes(ApiSecretPart2)

  val MaxSessionHeadersSize = 1000

  def singleApiCallSession(asPatId: PatId): TySession =
    TySession(
          patId = asPatId,
          // Maybe use now() instead? Shouldn't ever matter.
          createdAt = When.Genesis,
          version = CurVersion,
          startIp = None,
          startBrowserId = None,
          startHeaders = JsObject.empty,
          part1CompId = ApiSecretPart1,
          part2ForEmbgStorage = ApiSecretPart2,
          part2Hash = DummyHashPart2,
          part3ForDirJs = None,
          part3Hash = DummyHash,
          part4HttpOnly = None,
          part4Hash = DummyHash,
          part5Strict = None,
          part5Hash = DummyHash,
          isApiCall = true)

}
  type SidSt = St   // [Scala_3] opaque type
  type BrowserIdSt = St  // [Scala_3] opaque type

  def CHECK_AUTHN_STRENGTH = ()
      delete from sessions_t where site_id_c = ?
  implicit class PimpJsObjWithNullIfEmpty(jsOb: JsObject) {
    def orNullIfEmpty: AnyRef = {
      if (jsOb.value.isEmpty) Null(js.Types.OTHER)
      else jsOb
    }
  }

  def getByteArray(rs: js.ResultSet, column: St): Array[i8] = {
    val bytes = rs.getBytes(column)
    dieIf(bytes eq null, "TyERSNULLBYTES", s"Column $column is null, should be a bytea")
    bytes
  }

  def getOptByteArray(rs: js.ResultSet, column: St): Opt[Array[i8]] = {
    Opt(rs.getBytes(column))
  }

  with SessionsRdbMixin
  def deletePersonalDataFromOldSessions(): U = {
    TESTS_MISSING
    PRIVACY; COULD // make x months below configurable
    /*
    val deleteABitStatement = s"""
          update sessions_t set
            start_ip_c = start_ip_c & inet '255.255.255.0',
            start_headers_c = null
            -- start_browser_id_c = hash(..)
            forgotten_c = 1
          where
            forgotten_c = 0 and                    -- needs new col
            least(deleted_at_c, expired_at_c) < ?  -- needs ix  [sess_deleted_ix]
          """
    runUpdate(deleteABitStatement, List(now.minusMonths(SomeMonthsAgo).asTimestamp))

    val deleteMoreStatement = s"""
          update sessions_t set
            start_ip_c = start_ip_c & inet '255.255.0.0',
            start_headers_c = null,
            start_browser_id_c = null,
            forgotten_c = 2
          where
            forgotten_c = 1 and                    -- needs new col
            least(deleted_at_c, expired_at_c) < ?  -- needs ix
          """
    runUpdate(deleteMoreStatement, List(now.minusMonths(SomeYearsAgo).asTimestamp))
    */
  }



      delete from sessions_t
/**
 * Copyright (c) 2021 Kaj Magnus Lindberg
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.debiki.dao.rdb

import com.debiki.core._
import com.debiki.core.Prelude._
import Rdb._
import RdbUtil._
import java.sql.{ResultSet => j_ResultSet, SQLException => j_SQLException}
import collection.{mutable => mut}



/** Manages user sessions. But why are sessions stored in Postgres, not Redis?
  * For security reasons. It's good to be able to:
  * - Look up all current sessions by a certain user.
  * - Look up all sessions by ip addr.
  * - List sessions by time and user / ip.
  * - Remember if a session's ip address changes (could be suspicious — is there
  *   enough time for the person to travel to the new location?).
  * - Delete some or all of a user's session, and remember who did this and when.
  * - Delete all posts made by a session — in case someone got pwned.
  *
  * All that would be doable in Redis, but it's simpler, in Postgres. And,
  * combined with an app server in-process mem cache, this approach can also be made
  * faster than Redis (which is an out of process mem cache, slower).
  *
  * Maybe some time later, sessions will need to be temporarily buffered in Redis
  * or elsewhere somehow, and only written to Postgres every once in a while
  * (except for when logging out — that'd get persisted immediately).
  * Or even some other type of storage.  But that's in the distant future.
  */
trait SessionsRdbMixin extends SiteTransaction {
  self: RdbSiteTransaction =>


    /// This loads also sessions that have expired, but whose expired_at_c
    /// column hasn't been updated yet. — That's why there's "Maybe" in the
    /// val name — sessions that are *maybe* still active.
    private val AndMaybeActiveOnlySql =
      "and deleted_at_c is null and expired_at_c is null"

  def loadSession(part1Maybe2Or3: Opt[St] = None, part4HttpOnly: Opt[St] = None,
        maybeActiveOnly: Bo = false)
        : Opt[TySessionInDbMaybeBad] = {
    dieIf(part1Maybe2Or3.isDefined == part4HttpOnly.isDefined, "TyE50MG24SMP")
    val part1OrHash4 = part4HttpOnly.map(hashSha512FirstHalf32Bytes) getOrElse {
      // Compare with part 1 only (not 2 or 3).
      part1Maybe2Or3.getOrDie("TyE603MWEG657") take TySession.SidLengthCharsPart1
    }

    val andMaybeActiveOnlySql =
          if (maybeActiveOnly) AndMaybeActiveOnlySql else ""

    // Test, part 4: sso-test  TyT4ABKRW0268.TyTESESS123GONE
    // Test, part 1: All the time, whenever logged in.

    val colName = if (part1Maybe2Or3.isDefined) "part_1_comp_id_c" else "hash_4_http_only_c"
    val query = s"""
          select * from sessions_t
          where site_id_c = ?
            and $colName = ?
            $andMaybeActiveOnlySql """
    runQueryFindOneOrNone(query, List(siteId.asAnyRef, part1OrHash4), parseSession)
  }


  def loadOneOrTwoSessions(part1Maybe2Or3: Opt[St], part4HttpOnly: Opt[St],
        maybeActiveOnly: Bo = false): ImmSeq[TySessionInDbMaybeBad] = {
    if (part1Maybe2Or3.isEmpty && part4HttpOnly.isEmpty)
      return Vec.empty

    val values = MutArrBuf[AnyRef]()
    values.append(siteId.asAnyRef)

    val partOneEq = part1Maybe2Or3 map { part1Etc =>
      // Skip part 2 and 3.
      values.append(part1Etc take TySession.SidLengthCharsPart1)
      "part_1_comp_id_c = ?"
    } getOrElse ""

    val or =
          if (part1Maybe2Or3.isEmpty || part4HttpOnly.isEmpty) ""
          else "or"

    val partFourEq = part4HttpOnly map { part4 =>
      values.append(hashSha512FirstHalf32Bytes(part4))
      "hash_4_http_only_c = ?"
    } getOrElse ""

    val andMaybeActiveOnlySql =
          if (maybeActiveOnly) AndMaybeActiveOnlySql else ""

    val query = s"""
          select * from sessions_t
          where site_id_c = ? and ($partOneEq $or $partFourEq) $andMaybeActiveOnlySql"""

    runQueryFindMany(query, values.toList, parseSession)
  }


  def loadActiveSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad] = {
    val query = s"""
          -- ix: sessions_i_patid_createdat_active
          select * from sessions_t
          where site_id_c = ?
            and pat_id_c = ?
            and deleted_at_c is null
            and expired_at_c is null
          order by created_at_c desc  """
    runQueryFindMany(query, List(siteId.asAnyRef, patId.asAnyRef), parseSession)
  }


  def insertValidSession(session: TySession): U = {
    dieIf(session.part1CompId == TySession.ApiSecretPart1, "TyE7P02MRED1")
    dieIf(session.part2Hash sameElements TySession.DummyHashPart2, "TyE7P02MRED2")

    val statement = s"""
          insert into sessions_t (
              site_id_c,
              pat_id_c,
              created_at_c,
              deleted_at_c,
              expired_at_c,
              version_c,
              start_ip_c,
              start_headers_c,
              start_browser_id_c,
              part_1_comp_id_c,
              hash_2_for_embg_storage_c,
              hash_3_for_dir_js_c,
              hash_4_http_only_c,
              hash_5_strict_c)
          values (?, ?, ?, null, null, ?, ?::inet, ?, ?, ?, ?, ?, ?, ?)  """

    val values = List(
          siteId.asAnyRef,
          session.patId.asAnyRef,
          session.createdAt.asTimestamp,
          session.version.asAnyRef,
          session.startIp.orNullVarchar,
          session.startHeaders.orNullIfEmpty,
          session.startBrowserId.orNullVarchar,
          session.part1CompId,
          session.part2Hash,
          session.part3Hash,
          session.part4Hash,
          session.part5Hash)

    runUpdateSingleRow(statement, values)
  }


  def upsertSession(session: TySessionInDbMaybeBad): U = {
    dieIf(session.part1CompId == TySession.ApiSecretPart1, "TyE7P02MRED3")
    dieIf(session.part2HashForEmbgStorage sameElements TySession.DummyHashPart2, "TyE7P02MRED4")

    // Inserting expired sessions can be useful if restoring from a backup.
    val statement = """
          insert into sessions_t (
              site_id_c,
              pat_id_c,
              created_at_c,
              deleted_at_c,
              expired_at_c,
              version_c,
              start_ip_c,
              start_headers_c,
              start_browser_id_c,
              part_1_comp_id_c,
              hash_2_for_embg_storage_c,
              hash_3_for_dir_js_c,
              hash_4_http_only_c,
              hash_5_strict_c)
          values (?, ?, ?, ?, ?, ?, ?::inet, ?, ?, ?, ?, ?, ?, ?)
          on conflict (site_id_c, pat_id_c, created_at_c)   -- pk
          do update set
              deleted_at_c = least(sessions_t.deleted_at_c, excluded.deleted_at_c),
              expired_at_c = least(sessions_t.expired_at_c, excluded.expired_at_c)  """

    val values = List(
          siteId.asAnyRef,
          session.patId.asAnyRef,
          session.createdAt.asTimestamp,
          session.deletedAt.orNullTimestamp,
          session.expiredAt.orNullTimestamp,
          session.version.asAnyRef,
          session.startIp.orNullVarchar,
          session.startHeaders.orNullIfEmpty,
          session.startBrowserId.noneIfBlank.orNullVarchar,
          session.part1CompId,
          session.part2HashForEmbgStorage,
          session.part3HashForDirJs,
          session.part4HashHttpOnly,
          session.part5HashStrict)

    runUpdateSingleRow(statement, values)
  }


  private def parseSession(rs: j_ResultSet): TySessionInDbMaybeBad = {
    TySessionInDbMaybeBad(
          patId =  getInt32(rs, "pat_id_c"),
          createdAt = getWhen(rs, "created_at_c"),
          deletedAt = getOptWhen(rs, "deleted_at_c"),
          expiredAt = getOptWhen(rs, "expired_at_c"),
          version = getInt(rs, "version_c"),
          startIp = getOptString(rs, "start_ip_c"),
          startHeaders = getOptJsObject(rs, "start_headers_c") getOrElse JsEmptyObj2,
          startBrowserId = getOptString(rs, "start_browser_id_c"),
          part1CompId = getString(rs, "part_1_comp_id_c"),
          part2HashForEmbgStorage = getByteArray(rs, "hash_2_for_embg_storage_c"),
          part3HashForDirJs = getByteArray(rs, "hash_3_for_dir_js_c"),
          part4HashHttpOnly = getByteArray(rs, "hash_4_http_only_c"),
          part5HashStrict = getByteArray(rs, "hash_5_strict_c"))
  }

}
import talkyard.server.authn.MinAuthnStrength
  def logBrowserErrors: Action[JsValue] = PostJsonAction(
        RateLimits.BrowserError, MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 10000) {
          "Phoenix birds sleep at my fireplace, and more than one")
  def skipRateLimitsForThisSite: Action[JsValue] = PostJsonAction(
        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 150) {
            request =>
  def addAdminNotice: Action[JsValue] = PostJsonAction(
        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 50) { request =>
import talkyard.server.authn.MinAuthnStrength

  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
        RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { request =>
    CHECK_AUTHN_STRENGTH

  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {
  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {

    CHECK_AUTHN_STRENGTH
import talkyard.server.authn.MinAuthnStrength
        pageId: Option[String], postNr: Option[Int],
        ): Action[Unit] =
            GetActionRateLimited(
              RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

    val drafts: ImmSeq[Draft] = requester flatMap { theRequester =>
  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] = GetActionRateLimited(
        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
    CHECK_AUTHN_STRENGTH

        AsyncGetActionRateLimited(
            RateLimits.FetchLinkPreview, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

        GetActionRateLimited(RateLimits.ExpensiveGetRequest,
            MinAuthnStrength.EmbeddingStorageSid12) { request =>

    CHECK_AUTHN_STRENGTH

  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 5000) { request =>
    CHECK_AUTHN_STRENGTH

  def movePost: Action[JsValue] = StaffPostJsonAction(
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 300) { request =>
    CHECK_AUTHN_STRENGTH

  /** If in iframe, either no cookies get included, or all — so won't
    * run into problems with just parts of the session id being present,
    * and we can leave the authn strength at the default, MinAuthnStrength.Normal.
    *
    * Might need to ask the user to click a button in the iframe, triggering
    * iOS to show a dialog where the user can let the iframe use cookies.  [ios_itp]
    */
        CHECK_AUTHN_STRENGTH // done just above. Some time later, maybe also
        // some per category checks?
        // Apparently Safari can pop up a dialog where the user can let the iframe
        // use cookies, if hen interacts with the iframe. So, if a blog comments
        // discussion requires authn to read, Ty could show a button in the iframe,
        // like, "Click to authenticate"?
        // Then, if clicking, Safari would ask if the iframe was allowed to use cookies,
        // and (if answering Yes), one would get logged in directly, if there were
        // cookies already?  [ios_itp]

          request.anyTySession,
import talkyard.server.authn.MinAuthnStrength
  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost,
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2000) { request =>
    CHECK_AUTHN_STRENGTH

  private val ImpersonationCookieName = "esCoImp"  ; SECURITY ; SHOULD // add a HttpOnly cookie too? & delete. Both cookies required.
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, userId)
      createSessionIdAndXsrfToken(request, userId)._3
    val logoutCookies =
      if (anyUserId.isEmpty) DiscardingSessionCookies
    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookies: _*)
        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
              wasImpersonating = true)
                // Ignore old impersonation cookies, in case they're leaked somehow.
                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
                        wasImpersonating = true)
                  val (_, _, sidAndXsrfCookies) =
                        createSessionIdAndXsrfToken(request, oldUserId)
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, newUser.id)
            security.createSessionIdAndXsrfToken(request, guestUser.id)
  import context.security.DiscardingSessionCookies
  /** Clears session cookies and ends the session server side too; unsubscribes
    * from any websockets channel.
    SECURITY // optionally log out from all devices?
    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath,
          wasImpersonating = false)
  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St],
          wasImpersonating: Bo): Result = {
    AUDIT_LOG // session id destruction

      request.dao.logout(theRequester, bumpLastSeen = !wasImpersonating)
    dao.terminateSessionForCurReq(request.underlying)
    response.discardingCookies(DiscardingSessionCookies: _*)
  // Discard these also if logging in with username + password?  [clear_aun_cookies]
    // [.clearing_cookies] [clear_aun_cookies]
    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, member.id)
    // This not needed? These cookies, and many more, already cleared at the
    // end of the caller, tryLoginOrShowCreateUserDialog()  [.clearing_cookies].
    val (sid, _, sidAndXsrfCookies) =
          createSessionIdAndXsrfToken(request, loginGrant.user.id)
    import request.{body, dao}
          val (sid, _, sidAndXsrfCookies) =
                createSessionIdAndXsrfToken(request, newMember.id)
      COULD_OPTIMIZE // Clear login related cookies? [clear_aun_cookies]
      // Done already in the OAuth login controller; it clears these: CookiesToDiscardAfterLogin.

        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, user.id)
import talkyard.server.authn.MinAuthnStrength
  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
    CHECK_AUTHN_STRENGTH

    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, loginGrant.user.id)
import talkyard.server.authn.MinAuthnStrength
  def loadUserAnyDetails(who: St): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    // Maybe incl less data if embedded?
    CHECK_AUTHN_STRENGTH

    import request.{dao, theRequester => requester}
      // To do: Incl old expired sessions too? Or maybe that should be a separate
      // endpoint. [gdpr]
      val sessions = dao.listPatsSessions(userId)

        "browserIdDataRecentFirst" -> browserIdDataJson,
        "sessions" -> sessions.map(s => JsSession(s, inclPart1 = false)))
  def loadAuthorBlocks(postId: Int): Action[Unit] = GetActionRateLimited(
        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>

    CHECK_AUTHN_STRENGTH

  def loadMyPageData(pageIds: St): Action[U] = GetAction2(RateLimits.ReadsFromDb,
        MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

      anyTySession = request.anyTySession,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
        CHECK_AUTHN_STRENGTH // Skip the notifications, if in embedded iframe?

        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
    CHECK_AUTHN_STRENGTH // + may see?

  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
        maxBytes = 200) { request =>
  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
        maxBytes = 200) { request =>
  def snoozeNotifications(): Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
        maxBytes = 200) { request =>
  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) { request =>
    CHECK_AUTHN_STRENGTH

  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
  def listAllUsers(usernamePrefix: St): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
  def listUsernames(pageId: PageId, prefix: St): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH  // disallow if just sid part 1+2 but not embedded page

    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookies: _*)
import debiki.dao.NoUsersOnlineStuff
import talkyard.server.authn.MinAuthnStrength
  CHECK_AUTHN_STRENGTH
    // & sid leveL?
  CHECK_AUTHN_STRENGTH
      request.anyTySession,
  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2) { request =>
    CHECK_AUTHN_STRENGTH
        var forbiddenResp = ForbiddenResult(s"TyM0APPR_-$code", message)
        if (logout) {
          COULD_OPTIMIZE // do these two in the same tx:
          dao.logout(request.theReqer, bumpLastSeen = true)
          dao.terminateSessionForCurReq(request.underlying)

          forbiddenResp = forbiddenResp.discardingCookies(security.DiscardingSessionCookies: _*)
        }
        return Future.successful(forbiddenResp)
      request.anyTySession,
      request.anyTySession,
import talkyard.server.authn.MinAuthnStrength

  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost,
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) {
          request: JsonPostRequest =>
    CHECK_AUTHN_STRENGTH

  def loadVoters(postId: PostId, voteType: Int): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

  def parseJson(jsonSt: St): JsValue = {
    // Play uses JacksonJson.parseJsValue, see: play.api.libs.json.
    // which throws: IOException, JsonParseException, JsonMappingException,
    // and the latter are subclasses of IOException.
    try Json.parse(jsonSt)
    catch {
      case ex: java.io.IOException =>
        throwBadJson("TyEPARSJSN", s"Cannot parse text as json: ${ex.getMessage}")
    }
  }

      case x => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject, it is a: ${classNameOf(x)}")
      case x => throwBadJson("TyE0JSARR", s"$what is not a JsArray, it is a: ${classNameOf(x)}")
  def asInt64(json: JsValue, what: St): i64 =
    json match {
      case n: JsNumber =>
        try n.value.toLongExact
        catch {
          case _: java.lang.ArithmeticException =>
            throwBadJson("TyE0JSINT64", s"$what does not fit in a 64 bit integer")
        }
      case x =>
        throwBadJson("TyE0JSNUM", s"$what is not a JsNumber, it is a: ${classNameOf(x)}")
    }

  def asInt32(json: JsValue, what: St, min: Opt[i32] = None,
          max: Opt[i32] = None): i32 = {
    val int64 = asInt64(json, what)
    int64To32ThrowIfOutOfRange(int64, what, min = min, max = max)
  }

  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[IndexedSeq[JsValue]] =
      case a: JsArray => a.value
    val firstFieldValue = readOptLong(json, fieldName)
    firstFieldValue.orElse(readOptLong(json, altName)) map { valueAsLong =>
      val usedName = if (firstFieldValue.isDefined) fieldName else altName
      int64To32ThrowIfOutOfRange(valueAsLong, usedName, min = min, max = max)
  private def int64To32ThrowIfOutOfRange(valueAsLong: i64, name: St, min: Opt[i32] = None,
        max: Opt[i32] = None): i32 = {
    val maxVal = max getOrElse Int.MaxValue
    val minVal = min getOrElse Int.MinValue
    if (valueAsLong > maxVal)
      throwBadJson("TyEJSNGTMX", s"$name too large: $valueAsLong, max is: $maxVal")
    if (valueAsLong < minVal)
      throwBadJson("TyEJSNLTMN", s"$name too small: $valueAsLong, min is: $minVal")
    valueAsLong.toInt
  }
    parseInt64(json, fieldName)

  def parseInt64(json: JsValue, fieldName: St): i64 =
import ed.server.security.{SidStatus, SidOk}
      requestersJsonImpl(pageRequest.sid, requester, pageRequest.pageId, watchbarWithTitles,
      requestersJsonImpl(request.sid, requester, anyPageId = None, watchbarWithTitles,
  private def requestersJsonImpl(
        sid: SidStatus, requester: Participant, anyPageId: Option[PageId],
    // Parts 2 and other parts are all long enough themselves,  so we can include
    // part 1 in generated html pages. [sid_part1]
    val sidPart1: JsValue = sid match {
      case s: SidOk => JsString(s.part1CompId)
      case _ => JsNull
    }

      "mySidPart1" -> sidPart1,
  with talkyard.server.sess.SessionSiteDaoMixin
    readTxTryReuse(anyTx)(fn)

  def readTxTryReuse[R](anyTx: Opt[SiteTx])(fn: SiteTx => R): R =
  def deletePersonalDataFromOldSessions(): U = {
    writeTxLockAllSites { tx =>
      tx.deletePersonalDataFromOldSessions()
    }
  }

    writeTx { (tx, staleStuff) =>

      staleStuff.addPatIds(Set(userId))
      logout(user.noDetails, bumpLastSeen = false, anyTx = Some(tx, staleStuff))
      terminateSessions(  // [end_sess]
            forPatId = user.id, all = true, anyTx = Some(tx, staleStuff))
    }
  def logout(pat: Pat, bumpLastSeen: Bo, anyTx: Opt[(SiteTx, StaleStuff)] = None): U = {
    if (bumpLastSeen) writeTxTryReuse(anyTx) { (tx, staleStuff) =>
      staleStuff.addPatIds(Set(pat.id))

    UX; COULD // Maybe a WebSocket channel should remember which session & browser it
    // got started from? Rather than only what user. So can disconnect the right browser,
    // instead of all the user's browsers. For example, if impersonating,
    // then, this'll disconnect all current channels of the real person,
    // and mark hen as away (maybe incorrectly).
    // [which_ws_session]
    val (user, usersGroupIds) = writeTx { (tx, staleStuff) =>
      // logout(..) done below.  [end_sess]
      terminateSessions(forPatId = userId, all = true, anyTx = Some(tx, staleStuff))


    // Or just call:  logout(user, bumpLastSeen = false)
    pubSub.unsubscribeUser(siteId, user.briefUser)
    // terminateSessions() done above.
    new talkyard.server.sess.SessionController(cc, context),
import talkyard.server.authn.MinAuthnStrength
    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
          allowAnyone = true).async(f)
  def AsyncGetActionRateLimited(rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        )(f: GetRequest => Future[Result])
    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength).async(f)
  @deprecated("Use GetActionRateLimited instead, remove this, and rename it to this", "")
  def GetAction2(  // use GetActionRateLimited instead!
        rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        )(f: GetRequest => Result): Action[Unit] =
    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength)(f)

  SECURITY; COULD // let MinAuthnStrength be a param, so it's more explicitly visible
  // in the endpoint fn declaration, here and everywhere — don't let EmbeddedHalfSidOld
  // be the default anywhere?
    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
          allowAnyone = true)(f)
    PlainApiAction(cc.parsers.empty, rateLimits, MinAuthnStrength.EmbeddingStorageSid12,
          allowAnyone = true, avoidCookies = avoidCookies)(f)
  def GetActionRateLimited(
        rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
  def PostJsonAction(rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        maxBytes: Int,
        rateLimits, minAuthnStrength, allowAnyone = allowAnyone, isLogin = isLogin)(f)
  def PostTextAction(rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        maxBytes: Int, allowAnyone: Boolean = false)(
      rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
  def StaffPostJsonAction(
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
      cc.parsers.json(maxLength = maxBytes), minAuthnStrength)(f)
  def originOf(request: DebikiRequest[_]): St =
  def originOf(request: RequestHeader): St =
    *
    * Also needs any authn strength? Maybe: WhoInfo = ReqerInfo = Opt[(Pat, Opt[AuthnStrength])]?
    * Maybe Opt[(Pat, AuthnStrength)] where AuthnStrength can be [InternalJob], instead of None?
  def maySeePostUseCache(pageId: PageId, postNr: PostNr, user: Opt[Pat])
        : (MaySeeOrWhyNot, St) = {

  def anyTySession: Opt[TySession]
  def tySession: TySession = anyTySession getOrElse throwForbidden("TyE0SESS", "Not logged in")

    case SidOk(TySession.ApiSecretPart12, 0, _) => true
  val anyTySession: Opt[TySession],
  anyTySession: Opt[TySession],
    siteIdAndCanonicalHostname, anyTySession, sid, xsrfToken, browserId, user, pageExists,
import talkyard.server.authn.MinAuthnStrength
/** Play Framework Actions for requests to Talkyard's HTTP API.
  import security.DiscardingSessionCookies
  def PlainApiAction[B](
        parser: BodyParser[B],
        rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        allowAnyone: Bo = false,
        isLogin: Bo = false,
        avoidCookies: Bo = false,
        skipXsrfCheck: Bo = false,
        ): ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, rateLimits, minAuthnStrength = minAuthnStrength,
  def PlainApiActionStaffOnly[B](
          parser: BodyParser[B],
          minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
          ): ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, NoRateLimits, minAuthnStrength, staffOnly = true)
    PlainApiActionImpl(parser, rateLimits, MinAuthnStrength.ApiSecret, viaApiSecretOnly = true)
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
              Some(TySession.singleApiCallSession(asPatId = SysbotUserId)),
              SidOk(TySession.ApiSecretPart12, 0, Some(SysbotUserId)),
          Some(TySession.singleApiCallSession(asPatId = user.id)),
          // ... getting fixed, see TySession just above, [btr_sid].
          SidOk(TySession.ApiSecretPart12, 0, Some(user.id)),
          XsrfOk(TySession.ApiSecretPart12), None, block)
      // by iOS ITP or FF ETP or browser settings for 3rd-party-cookies.
      // the AvoidCookiesHeaderName header, so we won't forget that we should avoid cookies here.
      val CheckSidAndXsrfResult(
              anyTySession, actualSidStatus, xsrfOk, newCookies, delFancySidCookies) =
            corsInfo match {
          CheckSidAndXsrfResult(
                None, SidAbsent, XsrfOk("cors_no_xsrf"), Nil, Nil)
        case ci =>
          dieIf(ci.isCrossOrigin, "TyEJ2503TKHJ")
                request, anyRequestBody = Some(request.body), site, dao,
      val (mendedSidStatus, deleteAllSidCookies) =
                anyTySession, mendedSidStatus, xsrfOk, anyBrowserId, block)
          // CLEAN_UP have getUserBySessionId() return an error instead of throwing
            // (Does it make sense to delete any related session from sessions_t?)
              .discardingCookies(DiscardingSessionCookies: _*))
        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteAllSidCookies
              && delFancySidCookies.isEmpty) {
                  .withCookies(newCookies ::: newBrowserIdCookie: _*)
                  .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
            if (deleteAllSidCookies) {
              resultWithCookies =
                    resultWithCookies.discardingCookies(DiscardingSessionCookies: _*)
            }
            else if (delFancySidCookies.nonEmpty) {
                    resultWithCookies.discardingCookies(delFancySidCookies: _*)
          anyUserMaybeSuspended: Option[Participant],
          anyTySession: Opt[TySession], sidStatus: SidStatus,
      // in one browser and got logged out, when this request was going on already?
      if (anyUserMaybeSuspended.exists(_.isDeleted)) {
        // A race. Any session already deleted by UserDao [end_sess],
        dieIf(dao.listPatsSessions(anyUserMaybeSuspended.get.id).nonEmpty, "TyESTILLSID01")
              ForbiddenResult("TyEUSRDLD", "That account has been deleted")
                  .discardingCookies(DiscardingSessionCookies: _*))
      }
      if (isSuspended && request.method != "GET") {
        // A race. Any session already deleted by UserDao [end_sess],
        dieIf(dao.listPatsSessions(anyUserMaybeSuspended.get.id).nonEmpty, "TyESTILLSID02")
              ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
                  .discardingCookies(DiscardingSessionCookies: _*))
      }
            if (!isSuspended) anyUserMaybeSuspended
            else {
              // If suspended, one can still see publicly visible things.
              None
            }
      // Some staffOnly endpoints are okay with an embedded session — namely
      // if moderating embedded comments [mod_emb_coms_sid]. But admin endpoints always
      // need the full session id (not just parts 1+2+3, but also part 4 HttpOnly).
      dieIf((adminOnly || superAdminOnly) && !minAuthnStrength.fullSidRequired, "TyE502MGE6M1")

      if (anyTySession.exists(_.isApiCall)) {
        // Fine, we've checked an API secret, no session parts needed.
        assert(anyTySession.get.part1CompId == TySession.ApiSecretPart1, "TyE502MEG5")
      }
      else if (anyTySession.forall(_.part4Present)) {
        // Then either 1) the user isn't logged in; there is no session — that's fine;
        // we'll do authZ checks later; some endpoints (e.g. for logging in) are accessible
        // also if not logged in.
        // Or 2) the user is logged in, and this request does include the HttpOnly
        // session part — all fine.
      }
      else if (minAuthnStrength.fullSidRequired || staffOnly || adminOnly || superAdminOnly) {
        // Part 4 HttpOnly is required, but is missing.  Dupl code [btr_sid_part_4]
        assert(anyTySession.isDefined, "TyE04MWG245")
        assert(anyTySession.get.part4Absent, "TyE04MWG246")
        val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
        val onlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
        val doUseFancySid = onlyFancySid || tryFancySid
        if (doUseFancySid) {
          throwForbidden("TyEWEAKSID_",
                s"Please log out and log in, to get a complete session id — \n" +
                s"this endpoint, ${request.path}, requires the HttpOnly part of the session id")
        }
      }
      else {
        // Part 4 of the session is missing, and the current endpoint doesn't need part 4
        // (such endpoints are for embedded discussions — then, cookies don't work (they
        // generally don't work in iframes), so we're getting only session parts 1+2(+3) via
        // javascript and custom HTTP headers).
        dieUnless(anyTySession.exists(_.part4Absent), "TyE70MWEG25SM")
      }

        site, anyTySession, sidStatus, xsrfOk, browserId, anyUser, dao, request)
        // Harmless "problem": This code won't run, if we started handling the request,
        // but then an exception got thrown (e.g. a 404 Access Denied ResultException,
        // see above).  Then, the cookies won't get deleted — which doesn't really matter,
        // since they're unusable; session deleted server side already.  [btr_sid]
        result = result.map(_.discardingCookies(DiscardingSessionCookies: _*))(executionContext)
    anyTySession: Opt[TySession],
    browserId: Opt[BrowserId],
    user: Opt[Pat],
    anyTySession: Opt[TySession],
    browserId: Opt[BrowserId],
    user: Opt[Pat],
    dao.deletePersonalDataFromOldSessions()
import ed.server.security.CheckSidAndXsrfResult
import p.mvc.{Action, ControllerComponents, RequestHeader => play_RequestHeader, Result}
        request: play_RequestHeader =>
  private def webSocketImpl(request: play_RequestHeader): Future[Either[
  private def authenticateWebSocket(site: SiteBrief, request: play_RequestHeader)
    val CheckSidAndXsrfResult(anyTySession, sessionId, xsrfOk, newCookies, delFancySidCookies) =
                request, anyRequestBody = None, site, dao,
    COULD // delete any delFancySidCookies.

    // For now, let's require part 4 HttpOnly to use WebSocket. And some time later
    // maybe reconsider, and e.g. allow live updates of embedded comments.
    if (anyTySession.exists(_.part4Absent)) {
      // Dupl code [btr_sid_part_4]
      val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
      val onlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
      val doUseFancySid = onlyFancySid || tryFancySid
      if (doUseFancySid) {
        UNTESTED
        throwForbidden("TyEWEAKSIDWS",
              s"Please log out and log in, to get a complete session id — \n" +
              s"WebSockets, ${request.path}, requires the HttpOnly part of the session id")
      }
    }

    // For now, let's require a browser id cookie — then, *in some cases* simpler
          .discardingCookies(security.DiscardingSessionCookies: _*))
          .discardingCookies(security.DiscardingSessionCookies: _*))
    val authnReq = AuthnReqHeaderImpl(site, anyTySession, sessionId, xsrfOk,
 * Copyright (c) 2011-2017, 2021 Kaj Magnus Lindberg
import play.api.libs.json.{JsObject, JsString, JsValue}
import talkyard.server.sess.SessionSiteDaoMixin
import talkyard.server.http
import ed.server.http.AuthnReqHeader
private case class CheckSidResult(
  anyTySession: Opt[TySession],
  sidStatus: SidStatus,
  createCookies: List[Cookie] = Nil,
  discardCookies: List[DiscardingCookie] = Nil)


private object CheckSidResult {
  def noSession(
        sidStatus: SidStatus,
        // discardAllSessionCookies: Bo = false,
        discardCookies: List[DiscardingCookie] = Nil): CheckSidResult = {
    dieIf(sidStatus.canUse && sidStatus != SidAbsent, "TyE2MFKJ063I")
    //dieIf(discardAllSessionCookies && discardCookies.nonEmpty, "TyE2MFKJ0632")
    CheckSidResult(
          anyTySession = None, sidStatus, discardCookies = discardCookies)
          /* Cannot access here:
              if (discardAllSessionCookies) EdSecurity.DiscardingSessionCookies.toList
              else discardCookies) */
  }
}

case class SidExpired(
  minutesOld: i64,
  maxAgeMins: i64,
  wasForPatId: Opt[PatId]) extends SidStatus

case class SidDeleted(
  value: St,
  wasForPatId: Opt[PatId]) extends SidStatus {
}
// CLEAN_UP REFACTOR REMOVE this class? Use only TySession instead.
  // If fancy sid, is part 1 + 2 = 16 + 24 = 40 chars.
  // We should never include session id part 3 — so check that the length is just part 1 + 2
  // or that there's a '.' — then it's an old silly sid.
  dieIf(value.length != TySession.SidLengthCharsPart12 && !value.contains('.'),
        "TyEBADSID12LEN", s"Bad session id parts 1 + 2: '$value'")

  def part1CompId: St = value take TySession.SidLengthCharsPart1

case class CheckSidAndXsrfResult(
  anyTySession: Opt[TySession],
  sidStatus: SidStatus,
  xsrfStatus: XsrfOk,
  cookiesToAdd: List[Cookie],
  cookiesToDelete: List[DiscardingCookie])

  // See TySession [cookie_theory].
  val SessionIdPart123CookieName = "TyCoSid123"
  val SessionIdPart4HttpOnlyCookieName = "TyCoSid4"
  val SessionIdPart5StrictCookieName = "TyCoSid5"

        site: SiteBrief, dao: SessionSiteDaoMixin,
        expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
        : CheckSidAndXsrfResult = {


    // ----- Check session id
    val anySillySidInCookie: Opt[St] = urlDecodeCookie(SessionIdCookieName, request)
    val anySidHeaderValue: Opt[St] = request.headers.get(SessionIdHeaderName)
    val (anySillySidInHeader, anyFancySidPart12Maybe3InHeader) = anySidHeaderValue match {
      case None => (None, None)
      case Some(value) =>
        if (value.contains('.')) {
          // It's the old silly sid.
          (Some(value), None)
        }
        else {
          // It's the new fancy sid (Base64, no '.'), not the old silly sid.
          (None, Some(value))
        }
    }

    val anySillySid = anySillySidInCookie orElse anySillySidInHeader

    // New better sid:  [btr_sid]
    val anyFancySidPart123CookieVal = urlDecodeCookie(SessionIdPart123CookieName, request)
    val anyFancySidPart12Maybe3: Opt[St] =
                            anyFancySidPart123CookieVal orElse anyFancySidPart12Maybe3InHeader
    val anyFancySidPart4: Opt[St] = urlDecodeCookie(SessionIdPart4HttpOnlyCookieName, request)
    val anyFancySidPart5: Opt[St] = urlDecodeCookie(SessionIdPart5StrictCookieName, request)
    val checkSidResult: CheckSidResult =
          checkSessionId(
                site,
                anySillySid = anySillySid,
                anyFancySidPart12Maybe3 = anyFancySidPart12Maybe3,
                anyFancySidPart4 = anyFancySidPart4,
                anyFancySidPart5 = anyFancySidPart5,
                dao,
                now,
                expireIdleAfterMillis = expireIdleAfterMillis)

    val sessionIdStatus = checkSidResult.sidStatus
    val upgrToFancySidCookies = checkSidResult.createCookies
    val deleteFancySidCookies = checkSidResult.discardCookies  // rename? deleteFancySidCookies


    // ----- Check xsrf token
    val maybeCredentials = cookies.nonEmpty || anySidHeaderValue.isDefined
    val definitelyNoCreds = !maybeCredentials
    dieIf(definitelyNoCreds && sessionIdStatus != SidAbsent,
          "TyE50RMEG24", s"No creds, still, session id != SidAbsent: $sessionIdStatus")
    val (xsrf, newCookies): (XsrfOk, List[Cookie]) = {
        (xsrfOk, anyNewXsrfCookie)
      else if (isPost && definitelyNoCreds) {
        dieIf(sessionIdStatus != SidAbsent, "TyE502MWEG")
        (XsrfOk(""), Nil)
        val gotAnyCookie =
              anySillySidInCookie.isDefined ||
              anyFancySidPart123CookieVal.isDefined ||
              anyFancySidPart4.isDefined ||
              anyFancySidPart5.isDefined

                checkXsrfToken(
                    xsrfToken, anyXsrfCookieValue,
                    thereIsASidCookie = gotAnyCookie,
                    now, expireIdleAfterMillis = expireIdleAfterMillis)
        sessionIdStatus match {
          case SidBadFormat | SidBadHash =>
                      DiscardingSessionCookies: _*))
          case _ => // fine

        (xsrfOk, Nil)
    }

    dieIf(upgrToFancySidCookies.nonEmpty && deleteFancySidCookies.nonEmpty, "TyE5A6MRE25")

    val allCookiesToAdd =
          if (!maySetCookies) Nil
          else upgrToFancySidCookies:::newCookies
    CheckSidAndXsrfResult(
          checkSidResult.anyTySession,  // [btr_sid]
          sessionIdStatus,  // old, will remove later
          xsrf,
          cookiesToAdd = allCookiesToAdd,
          cookiesToDelete = deleteFancySidCookies)

  def createSessionIdAndXsrfToken(req: AuthnReqHeader, userId: PatId)
        : (SidOk, XsrfOk, List[Cookie]) = {

    AUDIT_LOG // Maybe log session id creation?

    val site = req.site
    val dao = req.dao

    val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
    val useFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
    val xsrfCookie = urlEncodeCookie(XsrfCookieName, xsrfOk.value,
          maxAgeSecs = Some(expireIdleAfterSecs + XsrfAliveExtraSeconds))

    // New better sid  [btr_sid]
    // ----------------------------------------

    if (tryFancySid || useFancySid) {
      val (newSidCookies, session) = genAndSaveFancySid(req, patId = userId,
            expireIdleAfterSecs = expireIdleAfterSecs, dao.now(),
            dao.asInstanceOf[SessionSiteDaoMixin])
      val sidOk = SidOk(session.part1And2, ageInMillis = 0, Some(userId))
      return (sidOk, xsrfOk, xsrfCookie :: newSidCookies)
    }

    // Old style sid  (signed cookie)
    // ----------------------------------------

    var sidOk = createSessionId(site, userId)

    COULD_OPTIMIZE // use ArrBuf
  private def genAndSaveFancySid(req: AuthnReqHeader, patId: PatId, expireIdleAfterSecs: i32,
          now: When, dao: SessionSiteDaoMixin): (List[Cookie], TySession) = {

    import com.debiki.core.{TySession => S}
    val totalEntropy = S.SidLengthCharsTotal * S.SidEntropyPerChar
    assert(totalEntropy == 96 + 144 + 144 + 144 + 96)
    val wholeSid = nextRandomString(totalEntropy, base36 = false, base64UrlSafe = true)
    dieIf(wholeSid.length != S.SidLengthCharsTotal, "TyESIDLEN538RMD",
          s"Generated a ${wholeSid.length} chars session id, but should be ${
          S.SidLengthCharsTotal} chars long. Here it is: '$wholeSid' (won't get used)")

    val lenUpTo1 = S.SidLengthCharsPart1
    val lenUpTo2 = lenUpTo1 + S.SidLengthCharsPart2
    val lenUpTo3 = lenUpTo2 + S.SidLengthCharsPart3
    val lenUpTo4 = lenUpTo3 + S.SidLengthCharsPart4

    val part1 = wholeSid.substring(0, lenUpTo1)
    val part2 = wholeSid.substring(lenUpTo1, lenUpTo2)
    val part3 = wholeSid.substring(lenUpTo2, lenUpTo3)
    val part4 = wholeSid.substring(lenUpTo3, lenUpTo4)
    val part5 = wholeSid.substring(lenUpTo4, S.SidLengthCharsTotal)

    assert(part1.length == S.SidLengthCharsPart1)
    assert(part2.length == S.SidLengthCharsPart2)
    assert(part3.length == S.SidLengthCharsPart3)
    assert(part4.length == S.SidLengthCharsPart4)
    assert(part5.length == S.SidLengthCharsPart5)

    val newSidPart123Cookie = urlEncodeCookie(
          SessionIdPart123CookieName, part1 + part2 + part3,
          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = false)

    val newSidPart4Cookie = urlEncodeCookie(
          SessionIdPart4HttpOnlyCookieName, part4,
          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = true)

    val newSidPart5Cookie = urlEncodeCookie(
          SessionIdPart5StrictCookieName, part5,
          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = true, sameSiteStrict = true)

    val startHeaders = {
      import http.{HeaderNamesLowercase => H}
      // New & nice:
      val chUserAgent = req.headers.get(H.ClientHintUserAgent).trimNoneIfBlank
      val chUaMobile = req.headers.get(H.ClientHintUaMobile).trimNoneIfBlank
      val chUaPlatform = req.headers.get(H.ClientHintUaPlatform).trimNoneIfBlank
      // Old & verbose:
      val userAgent = req.headers.get(H.UserAgent).trimNoneIfBlank

      val mapBuilder = Map.newBuilder[St, JsValue]
      val maxLen = 200 // header max length. 200 is a lot?
      val mobLen = 20  // should be just "?0" or "?1"
      chUserAgent.foreach(v => mapBuilder += H.ClientHintUserAgent -> JsString(v take maxLen))
      chUaMobile.foreach(v => mapBuilder += H.ClientHintUaMobile -> JsString(v take mobLen))
      chUaPlatform.foreach(v => mapBuilder += H.ClientHintUaPlatform -> JsString(v take maxLen))

      // Skip the verbose User-Agent header if we got the new & better client hint header.
      if (chUserAgent.isEmpty) {
        userAgent.foreach(v => mapBuilder += H.UserAgent -> JsString(v take maxLen))
      }
      JsObject(mapBuilder.result)
    }

    val session = TySession(
          patId = patId,
          createdAt = now,
          version = TySession.CurVersion,
          startIp = Some(req.ip),
          startBrowserId = req.browserId.map(_.cookieValue),
          startHeaders = startHeaders,
          part1CompId = part1,
          part2ForEmbgStorage = part2,
          part2Hash = hashSha512FirstHalf32Bytes(part2),
          part3ForDirJs = Some(part3),
          part3Hash = hashSha512FirstHalf32Bytes(part3),
          part4HttpOnly = Some(part4),
          part4Hash = hashSha512FirstHalf32Bytes(part4),
          part5Strict = Some(part5),
          part5Hash = hashSha512FirstHalf32Bytes(part5))

    dao.insertValidSession(session)

    (List(newSidPart123Cookie, newSidPart4Cookie, newSidPart5Cookie), session)
  }
  // 15 chars is 90 bits entropy (15 * 6 bits, using Base64) — that's more than enough:
  // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
  // >  The session ID value must provide at least 64 bits of entropy
  // 11 chars is 66 bits entropy (with a perfect rand num gen).
  private val HashLength: i32 = 15

  private def secretSalt: St = globals.applicationSecret


  private def checkSessionId(
        site: SiteBrief,
        anySillySid: Opt[St],
        anyFancySidPart12Maybe3: Opt[St],
        anyFancySidPart4: Opt[St],
        anyFancySidPart5: Opt[St],
        dao: SessionSiteDaoMixin,
        now: When,
        expireIdleAfterMillis: i64): CheckSidResult = {

    val hasFancySid = anyFancySidPart12Maybe3.isDefined || anyFancySidPart4.isDefined ||
          anyFancySidPart5.isDefined
    val useOnlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
    val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)

    if (useOnlyFancySid || (tryFancySid && hasFancySid)) {
      var result = checkFancySessionId(anyPart12Maybe3 = anyFancySidPart12Maybe3,
            anyPart4 = anyFancySidPart4, anyPart5 = anyFancySidPart5,
            dao, now, expireIdleAfterMillis = expireIdleAfterMillis)
      if (anySillySid.isDefined) {
        result = result.copy(
              discardCookies = DiscardingSillySidCookie :: result.discardCookies)
      }
      result
    }
    else {
      var result = checkSillySessionId(site, anySillySid, dao, now, expireIdleAfterMillis)
      if (hasFancySid) {
        result = result.copy(
              discardCookies = DiscardingFancySidCookies ::: result.discardCookies)
      }
      result
    }
  }


  private def checkFancySessionId(  // [btr_sid]
        anyPart12Maybe3: Opt[St], anyPart4: Opt[St], anyPart5: Opt[St],
        dao: SessionSiteDaoMixin, now: When,
        expireIdleAfterMillis: i64): CheckSidResult = {

    val anySession = anyPart12Maybe3 flatMap dao.getSessionByPart1

    val session: TySessionInDbMaybeBad = anySession getOrElse {
      // If pat has logged out client side, when offline, the not-HttpOnly session parts
      // would be gone, whilst we'd still get the 4th and 5th parts in HttpOnly cookies.
      // If so, we should delete the session server side too —
      // so, let's look it up by part 4 — there's an index on part 4 (not part 5), because
      // part 4 should always be present, if part 5 is present (part 4 is SameSite Lax,
      // part 5 Strict).

      // Maybe maybe Safari will sometimes mistakenly auto clear localStorage?
      // See [ios_itp] in maybe-later.txt.

      anyPart4.flatMap(dao.getSessionByPart4HttpOnly(_, maybeActiveOnly = true)) foreach { s =>
        // Test: sso-test  TyT4ABKRW0268.TyTESESS123GONE
        AUDIT_LOG // client side logout
        val sessionDeleted = s.copy(deletedAt = Some(now))
        dao.updateSession(sessionDeleted)
      }

      // Session gone. Delete any cookies.
      val gotCookies = anyPart12Maybe3.isDefined || anyPart4.isDefined || anyPart5.isDefined

      return CheckSidResult.noSession(
            SidAbsent,
            discardCookies = if (gotCookies) DiscardingFancySidCookies else Nil)
    }

    import TySession._

    val thePart12Maybe3: St = anyPart12Maybe3 getOrDie "TyE3MG70QFM2"
    val thePart2: St = thePart12Maybe3.substring(SidLengthCharsPart1, SidLengthCharsPart12)
    val hashPart2: Array[i8] = hashSha512FirstHalf32Bytes(thePart2)
    val part3IsPresent = thePart12Maybe3.length > TySession.SidLengthCharsPart12
    val anyPart3: Opt[St] =
          if (!part3IsPresent) None
          else Some(thePart12Maybe3.substring(SidLengthCharsPart12, SidLengthCharsPart123))

    val anyPart3Hash = anyPart3.map(hashSha512FirstHalf32Bytes)
    val anyPart4Hash = anyPart4.map(hashSha512FirstHalf32Bytes)
    val anyPart5Hash = anyPart5.map(hashSha512FirstHalf32Bytes)

    // Part 2, and 3, 4, 5 if present, must be from the same session.
    // We've checked part 1 already — we found the session via part 1.

    val badPart2 = !hashPart2.sameElements(session.part2HashForEmbgStorage)
    val badPart3 = anyPart3Hash.map(_ sameElements session.part3HashForDirJs) is false
    val badPart4 = anyPart4Hash.map(_ sameElements session.part4HashHttpOnly) is false
    val badPart5 = anyPart5Hash.map(_ sameElements session.part5HashStrict) is false

    if (badPart2 || badPart3 || badPart4 || badPart5) {
      AUDIT_LOG // this is suspicious?

      val sessionDeleted = session.copy(deletedAt = Some(now))
      dao.updateSession(sessionDeleted)

      // Maybe part 4 is from another older/newer session somehow? That'd be weird.
      // Then, invalidate that session too.
      // (Since parts 123 are not-HttpOnly, but parts 4 and 5 are HttpOnly,
      // they could get out of sync maybe because of some unknown bug, or if a script
      // or a person manipulates their cookies.)
      if (badPart4) {
        COULD_OPTIMIZE // We've hashed part 4 above, need not do again in
        // SessionsRdbMixin.loadSession().
        dao.getSessionByPart4HttpOnly(anyPart4.get, maybeActiveOnly = true) foreach {
              differentSession =>
          // The database should prevent any parts being the same (unique indexes,
          // on parts 1 and 4, but not 2, 3 or 5).
          AUDIT_LOG // that we're deleting another session?
          UNTESTED ; TESTS_MISSING  // TyTSESSHALFBAD

          warnDevDieIf(differentSession.part1CompId == session.part1CompId, "TyE603MSEJ56")
          warnDevDieUnless(differentSession.part4HashHttpOnly sameElements
                session.part4HashHttpOnly, "TyE603MSEJ56")

          val differentSessionDeleted = differentSession.copy(deletedAt = Some(now))
          dao.updateSession(differentSessionDeleted)
        }
      }

      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)
    }

    if (session.isDeleted || session.hasExpired)
      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)

    // Did the session expire just now?  [lazy_expire_sessions]
    //
    // Maybe instead:
    // val justExpired = session.expiresNow(now, expireIdleAfterMillis / MillisPerMinute)

    UX; SHOULD // bump lastSeenAt somehow, and add idle time to that? [bump_sid_last_use]

    val expiresAt = expireIdleAfterMillis + session.createdAt.millis
    if (expiresAt < now.millis) {
      AUDIT_LOG // session lazy-expired
      // Then don't allow using the session any more, even if the server time gets
      // changed back to before when it expired.
      val sessionExpired = session.copy(expiredAt = Some(now))
      dao.updateSession(sessionExpired)
      // or use SidExpired instead of SidAbsent. But why?
      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)
    }

    val sidOk = SidOk(value = thePart12Maybe3.take(SidLengthCharsPart12),
          ageInMillis = now.millis - session.createdAt.millis,
          userId = Some(session.patId))

    CheckSidResult(
          Some(session.copyAsValid(
              // Parts 3, 4, 5 are optional. (Parts 1 and 2? We found the session via part 1,
              // and part 2 is required, and we have compared it with the session
              // in the database already, above.)
              part2 = thePart2,
              part3 = anyPart3,
              part4 = anyPart4,
              part5 = anyPart5)),
          sidOk)
  }


  private def checkSillySessionId(site: SiteBrief, anyOldSid: Opt[St],
        dao: SessionSiteDaoMixin, now: When,
        expireIdleAfterMillis: i64): CheckSidResult = {

    val value = anyOldSid getOrElse {
      return CheckSidResult.noSession(SidAbsent)
    }

    if (value.length <= HashLength)
      return CheckSidResult.noSession(SidBadFormat)

      s"$secretSalt.${site.id}$dotUseridDateRandom") take HashLength

    if (hash != realHash)
      return CheckSidResult.noSession(SidBadHash)

    val oldOkSid = dotUseridDateRandom.drop(1).split('.') match {
            return CheckSidResult.noSession(SidBadFormat)
        if (ageMillis > expireIdleAfterMillis) {
          val expiredSid = SidExpired(
                  minutesOld = ageMillis / MillisPerMinute,
                  maxAgeMins = expireIdleAfterMillis / MillisPerMinute,
                  wasForPatId = userId)
          return CheckSidResult.noSession(expiredSid)
        }

    var newSidCookies: List[Cookie] = Nil

    // Upgrade old sid to new style sid:  [btr_sid]
    // ----------------------------------------

    /* Maybe skip this. Hard to test?
    if ((tryFancySid || useFancySid) && oldOkSid.userId.isDefined) {
      val dao = anyDao getOrDie "TyE50FREN68"
      val patId = oldOkSid.userId getOrDie "TyE602MTEGPH"
      val settings = dao.getWholeSiteSettings()
      val expireIdleAfterSecs = settings.expireIdleAfterMins * 60
      val (newCookies, sidPart1, sidPart2) =
            genAndSaveFancySid(patId = patId, expireIdleAfterSecs, dao.redisCache,
                isOldUpgraded = true)
      // cookies = newSidPart1Cookie::newSidPart2Cookie::cookies
      result = SidOk(sidPart1,
            expireIdleAfterSecs * 1000, Some(patId))
      newSidCookies = newCookies
    }
    */

    CheckSidResult(anyTySession = None, oldOkSid, createCookies = newSidCookies)
  @deprecated("Now", "Use the fancy session id instead.")
  private def createSessionId(site: SiteBrief, userId: PatId): SidOk = {

      s"$secretSalt.${site.id}.$useridDateRandom") take HashLength
    val value = s"$saltedHash.$useridDateRandom"
    SidOk(value, ageInMillis = 0, Some(userId))
  def SecureCookie(name: St, value: St, maxAgeSeconds: Opt[i32] = None,
        httpOnly: Bo = false): Cookie =
    SECURITY // Remove this? No longer needed — instead, session parts 1-5,
    // and 5 is always strict.
  def DiscardingSessionCookies: List[DiscardingCookie] =
    List(DiscardingSecureCookie(SessionIdCookieName),
        DiscardingSecureCookie(SessionIdPart123CookieName),
        DiscardingSecureCookie(SessionIdPart4HttpOnlyCookieName),
        DiscardingSecureCookie(SessionIdPart5StrictCookieName))

  def DiscardingSillySidCookie: DiscardingCookie =
    DiscardingSecureCookie(SessionIdCookieName)

  def DiscardingFancySidCookies: List[DiscardingCookie] =
    List(DiscardingSecureCookie(SessionIdPart123CookieName),
        DiscardingSecureCookie(SessionIdPart4HttpOnlyCookieName),
        DiscardingSecureCookie(SessionIdPart5StrictCookieName))

  //    Set-Cookie: dwCoUserEmail="someone%40exaple.com";Path=/
  //    Set-Cookie: dwCoUserName="space%20text";Path=/
  //    Set-Cookie: dwCoUserEmail=someone@example.com;Path=/
  //    Set-Cookie: dwCoUserName="space text";Path=/
  private def urlEncodeCookie(name: St, value: St, maxAgeSecs: Opt[i32] = None,
        httpOnly: Bo = false, sameSiteStrict: Bo = false) =
      // Don't set — if set to, say, example.com, then, vulnerabilities at
      // www.example.com might allow an attacker to get access to cookies
      // from secure.example.com
      // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#domain-and-path-attributes
      sameSite =
            // If debugging on localhost over http, does Strict work?
            if (sameSiteStrict) Some(Cookie.SameSite.Strict)
            else anySameSiteCookieValue(),
      httpOnly = httpOnly)
  /// ts: Session
  def JsSession(sess: TySessionInDbMaybeBad, inclPart1: Bo = true): JsObject = {
    // Don't include the actual session id. (That is, exclude parts 2 – 5. *Could* maybe
    // theoretically include them, since they're hashes, but bad idea, and not needed.)
    var json = Json.obj(
          "patId" -> sess.patId,
          "createdAt" -> JsWhenMs(sess.createdAt),
          "deletedAt" -> JsWhenMsOrNull(sess.deletedAt),
          "expiredAt" -> JsWhenMsOrNull(sess.expiredAt),
          "version" -> JsNumber(sess.version),
          "startHeaders" -> sess.startHeaders)
    if (inclPart1) {
      json += "part1" -> JsString(sess.part1CompId)
    }
    json
  }


      val nestedQueries: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "manyQueries")
      val nestedActions: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "doActions")
        val (sid, _, sidAndXsrfCookies) =
              security.createSessionIdAndXsrfToken(request, user.id)
          security.createSessionIdAndXsrfToken(req, user.id)
      "weakSessionId" -> JsString(sid.value)))  // [NOCOOKIES]
  sealed abstract class MinAuthnStrength(val IntVal: i32, val fullSidRequired: Bo = true) {
    def toInt: i32 = IntVal
  }


  // RENAME to AuthnStrength? And a *param* name can be minAuthnStrength instead?
  // Then, AuthnStrength.[InternalJob] makes sense, otherwise not.
  //
  object MinAuthnStrength {

    // No session id needed — instead, we check an e2e test secret, for each request.
    case object E2eTestPassword extends MinAuthnStrength(10, fullSidRequired = false)

    // No session id needed — instead, we check an API secret, for each request.
    case object ApiSecret extends MinAuthnStrength(15, fullSidRequired = false)

    /// Parts 1 and 2 of old embedded sessions might have been cached in localStorage
    /// in the embedding website, and if the embedding website has security vulnerabilities,
    /// an attacker might have gained access to those parts of the session.
    /// Therefore, those parts of the session should give access only to
    /// embedded comments — like, posting comments, editing one's comments,
    /// or (for mods) moderating embedded comments [mod_emb_coms_sid],
    /// but nothing else. Then, if a blog has security vulnerabilities, only
    /// the blog comments would be affected — but not any Talkyard forum on its own
    /// sub domain (for organizations with both a forum and blog comments),
    /// or any user accounts or email addresses.
    ///
    /// This could also be configurable, so the site admins could choose to always
    /// require session part 3, or even require people to interact with the comments
    /// iframe, trying to make iOS Safari ITP understand that the comments aren't
    /// a tracker, and allow cookies — then, the server would get the HttpOnly
    /// cookies. [ios_itp]
    ///
    case object EmbeddingStorageSid12 extends MinAuthnStrength(20, fullSidRequired = false)

    /// The embedded sid can be refreshed by popping up a window directly
    /// against the Talkyard server — then, the session id part 3, in a cookie, would
    /// be accessible to the javascript in the popup, which could send it to the iframes.
    /// Or the app server could reply and include parts 1, 2, 3 in the response.
    /// But only sids part 1+2 (without part 3), would get rejected (since they're
    /// less safe, see EmbeddingStorageSid12 above). Part 3 would be temporarily
    /// remembered only in the iframes, never accessible to the embedd*ing* website,
    //case object EmbeddedIframeSid123 extends MinAuthnStrength(30, part3Required = true)

    //case object EmbeddedIframeSid123Recent extends MinAuthnStrength(35, part3Required = true)

    /// Apart from requiring HttpOnly cookies (session id part 4), what Normal
    /// authentication is, would be community specific. Some communities might be ok
    /// with password authn — whilst others might want 2 or 3 factor authn,
    /// and/or SameSite Strict cookies.
    case object Normal extends MinAuthnStrength(40)

    /// Like Normal, but must have authenticated recently. Could protect against
    /// someone grabbing another person's laptop, running away, and then 5 minutes later
    /// trying to change the other person's email address to hens own — this wouldn't work,
    /// since hen would need to authenticate again first (so has authenticated just recently).
    //case object NormalRecent extends MinAuthnStrength(45)

    /// A community can optionally require stronger authentication, for some endpoints
    /// (or maybe even some specific parts of a community?), or custom groups, mods or admins.
    /// By default, Strong is the same as Normal — the site admins would need to
    /// specify somehow what Strong means, in their community's case. Maybe
    /// OTP + 2FA could be a default? Or should WebAuthn be the default Strong method?
    /// (Unlike 2FA in general, WebAuthn works against phishing.)
    //case object Strong extends MinAuthnStrength(50)

    /// Like Strong, but must have authenticated recently — say, within the last X minutes
    /// (and X is community specific).
    /// For things like changing one's email address or deleting one's account.
    //case object StrongRecent extends MinAuthnStrength(55)

    /// Maybe useful if min-auth-strength fn gets called by an internal background job?
    // case object [InternalJob] extends MinAuthnStrength(99)
  }


package talkyard.server

import com.debiki.core._
import play.api.http.{HeaderNames => play_HeaderNames}

package object http {

  val UserAgentHeaderNormalLength = 100


  object HeaderNamesLowercase {

    val UserAgent: St = play_HeaderNames.USER_AGENT.toLowerCase

    val AcceptCH = "accept-ch"

    // ----- User Agent

    // The user agent related headers are:
    // - User-Agent
    // - Sec-CH-UA-Mobile    e.g. "?0"
    // - Sec-CH-UA-Platform  e.g. "Linux"
    // - Sec-CH-UA     e.g. "Google Chrome";v="95", "Chromium";v="95", ";Not A Brand";v="99"

    // Sec-CH-UA, e.g. "Google Chrome";v="95", "Chromium";v="95", ";Not A Brand";v="99"
    val ClientHintUserAgent = "sec-ch-ua"

    // Sec-CH-UA-Mobile: "?0" or "?1"
    val ClientHintUaMobile = "sec-ch-ua-mobile"

    // Sec-CH-UA-Platform: e.g. "Linux"
    val ClientHintUaPlatform = "sec-ch-ua-platform"

    val ClientHintHeaders: Vec[St] = Vec(
          ClientHintUserAgent,
          ClientHintUaMobile,
          ClientHintUaPlatform)

    val ClientHintHeadersAndUserAgent: Vec[St] =
          ClientHintHeaders :+ UserAgent

    // ----- Network related

    val SaveData = "save-data"

    // Effective connection type: slow-2g, 2g, 3g, 4g.
    val Ect = "ect"

    // Approximate round trip time in millis, including application server processing time.
    val Rtt = "rtt"

    // Approximate bandwidth of the client's connection to the server, in Mbps.
    val Downlink = "downlink"
  }

}
/**
 * Copyright (c) 2021 Kaj Magnus Lindberg
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package talkyard.server.sess

import com.debiki.core._
import debiki.RateLimits
import debiki.dao.SiteDao
import debiki.EdHttp._
import ed.server.{EdContext, EdController}
import controllers.Utils.OkApiJson
import play.api.libs.json._
import javax.inject.Inject
import play.api.mvc.{Action, ControllerComponents}
import talkyard.server.JsX._
import debiki.JsonUtils._


class SessionController @Inject()(cc: ControllerComponents, edContext: EdContext)
  extends EdController(cc, edContext) {


  def listSessions(patId: PatId): Action[U] = GetAction { req =>
    import req.{dao, theRequester => reqer}
    throwForbiddenIfMayNot("view", dao, reqer, patId)
    val activeSessions = dao.listPatsSessions(patId)
    val json = Json.obj("sessions" -> JsArray(activeSessions.map(s => JsSession(s))))
    OkApiJson(json)
  }


  def terminateSessions: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
        maxBytes = 2000) { req =>
    import req.{dao, theRequester => reqer}

    val body = asJsObject(req.body, "The request body")
    val forPatId = parseInt32(body, "forPatId")

    throwForbiddenIfMayNot("terminate", dao, reqer, forPatId)

    val terminateAllOthers: Bo = parseOptBo(body, "all") getOrElse false
    val allButNot = if (!terminateAllOthers) None else Some(req.tySession.createdAt)

    val sessionsStartedAtArr: Seq[JsValue] = parseOptJsArray(
          body, "sessionsStartedAt") getOrElse Nil

    val startTimesMs: Seq[When] = sessionsStartedAtArr map { jsVal =>
      When.fromMillis(asInt64(jsVal, "Session start date-time"))
    }

    throwBadReqIf(startTimesMs.nonEmpty && allButNot.isDefined, "TyE4MWJ0202",
          "Don't specify both 'all' and 'sessionsStartedAt'")

    throwBadReqIf(startTimesMs.isEmpty && allButNot.isEmpty, "TyE4MWJ0203",
          "Specify one of 'all' and 'sessionsStartedAt'")

    val terminatedSessions = dao.terminateSessions(
          forPatId, thoseStartedAt = startTimesMs, allButNot)

    val json = Json.obj(
        "terminatedSessions" -> JsArray(terminatedSessions.map(s => JsSession(s))))

    OkApiJson(json)
  }


  private def throwForbiddenIfMayNot(doWhat: St, dao: SiteDao, reqer: Pat, patId: PatId): U = {
    throwForbiddenIf(reqer.id != patId && !reqer.isStaff, "TyE0YOURSESS",
          s"Cannot $doWhat other people's sessions")
    val pat = dao.getTheParticipant(patId)
    throwForbiddenIf(pat.isAdmin && !reqer.isAdmin, "TyEADMINSESS_",
          s"Cannot $doWhat an admin's sessions")
  }

}

/**
 * Copyright (c) 2021 Kaj Magnus Lindberg
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package talkyard.server.sess

import com.debiki.core._
import com.debiki.core.Prelude._
import debiki.EdHttp.urlDecodeCookie
import debiki.dao.SiteDao
import talkyard.server.dao.StaleStuff



trait SessionSiteDaoMixin {
  self: SiteDao =>


  def listPatsSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad] = {
    val sessionsMaybeBad = readTx(_.loadActiveSessions(patId))
    SECURITY; COULD // also incl recently ended sessions:
    //val recentlyActiveSessions = readTx(_.loadRecentlyEndedSessions(patId, limit = 10)) ?

    // Lazy-mark as expired sessions that has just expired.  [lazy_expire_sessions]
    val settings = getWholeSiteSettings()
    val now = this.now()
    val (justExpiredSessions, activeSessions) = sessionsMaybeBad.partition(
          _.expiresNow(now, expireIdleAfterMins = settings.expireIdleAfterMins))
    for (session <- justExpiredSessions) {
      val sessionExpired = session.copy(expiredAt = Some(now))
      updateSession(sessionExpired)
    }

    activeSessions
  }

  def getSessionByPart1(part1Maybe2Or3: St): Opt[TySessionInDbMaybeBad] = {
    readTx(_.loadSession(part1Maybe2Or3 = Some(part1Maybe2Or3)))
     // + cache
  }


  /// If maybeActiveOnly, won't load sessions that have been terminated for sure,
  /// but does load sessions that might have expired, but that has not yet been
  /// marked as expired in the database.
  ///
  def getSessionByPart4HttpOnly(part4: St, maybeActiveOnly: Bo): Opt[TySessionInDbMaybeBad] = {
    readTx(_.loadSession(part4HttpOnly = Some(part4), maybeActiveOnly = maybeActiveOnly))
     // + cache
  }


  def insertValidSession(session: TySession): U = {
    writeTx { (tx, _) =>
     tx.insertValidSession(session)
     // + cache
    }
  }


  def updateSession(session: TySessionInDbMaybeBad): U = {
    writeTx { (tx, _) =>
     tx.upsertSession(session)

     // + uncache
    }
  }


  def terminateSessionForCurReq(req: play.api.mvc.RequestHeader): U = {
    REFACTOR // Move these cookie names — and their whole file — to this pkg.
    // Thereafter, can remove this import.
    import ed.server.security.EdSecurity._

    val sidPart12Maybe3FromHeader: Opt[St] = req.headers.get(SessionIdHeaderName)
    val sidPart123FromCookie: Opt[St] =
          urlDecodeCookie(SessionIdPart123CookieName, req)

    val sidPart4FromCookie: Opt[St] =
          urlDecodeCookie(SessionIdPart4HttpOnlyCookieName, req)

    // Maybe some day some bug can cause the browser to send two different
    // session part 1 values. Then we'd better delete both sessions.
    sidPart12Maybe3FromHeader foreach { partsInHeader =>
      val twoDifferentPart1 = sidPart123FromCookie.exists(!_.startsWith(partsInHeader))
      if (twoDifferentPart1) {
        AUDIT_LOG // Is this weird? A script might have has tampered with the cookie
        // or header?  For now, to catch bugs:
        warnDevDie("TyE70MWEPG246")
      }
      terminateAnySession(
            sidPart1Maybe2Or3 = Some(partsInHeader),
            sidPart4 = None)
    }

    terminateAnySession(
          sidPart1Maybe2Or3 = sidPart123FromCookie,
          sidPart4 = sidPart4FromCookie)
  }


  private def terminateAnySession(sidPart1Maybe2Or3: Opt[St], sidPart4: Opt[St]): U = {
    writeTx { (tx, _) =>
      val sessions = tx.loadOneOrTwoSessions(sidPart1Maybe2Or3, part4HttpOnly = sidPart4,
            maybeActiveOnly = true)
      assert(sessions.forall(_.wasValidJustRecently))  // could remove the filter() below
      val terminatedSessions =
            sessions.filter(_.wasValidJustRecently)
                .map(_.copy(deletedAt = Some(tx.now)))
      terminatedSessions foreach tx.upsertSession

       // + uncache
    }
    AUDIT_LOG // 0, 1 or 2 sessions got terminated.
  }


  /// Returns any sessions that got terminated; isDeleted will be Some(current-time).
  /// This might include sessions that expired just moments ago — they'll
  /// be both expiredAt = Some(..) and deletedAt = Some(..).
  ///
  def terminateSessions(forPatId: PatId, thoseStartedAt: Seq[When] = Nil,
          allExceptFor: Opt[When] = None, all: Bo = false,
          anyTx: Opt[(SiteTx, StaleStuff)] = None)
          : ImmSeq[TySessionInDbMaybeBad] = {

    dieIf(all && thoseStartedAt.nonEmpty, "TyE60MWEPJ22")
    dieIf(all && allExceptFor.isDefined, "TyE60MWEPJ23")
    dieIf(thoseStartedAt.nonEmpty && allExceptFor.isDefined, "TyE60MWEPJ24")

    val sessionsMaybeBad = readTxTryReuse(anyTx.map(_._1))(_.loadActiveSessions(forPatId))
    val sessionsToEnd = sessionsMaybeBad filter { sess =>
      warnDevDieIf(!sess.wasValidJustRecently, "TyE5MWJY20X")
      all || thoseStartedAt.contains(sess.createdAt) ||
            allExceptFor.isSomethingButNot(sess.createdAt)
    }

    val now = anyTx.map(_._1.now) getOrElse this.now()
    val terminatedSessions = sessionsToEnd.map(_.copy(deletedAt = Some(now)))

    if (terminatedSessions.nonEmpty) {
      writeTxTryReuse(anyTx) { (tx, _) =>
        terminatedSessions foreach tx.upsertSession

        // + uncache
      }
    }

    terminatedSessions
  }

}
// Maybe store this in Me instead? Next to Me.mySidPart1? [sess_in_me]
        isLoggedIn: store.me.isLoggedIn,
    /* COULD_OPTIMIZE: Clear these cookies, maybe all authn cookies? [clear_aun_cookies]
     * Clear cookies both server side — and from here too, in case closing the dialog
     * without logging in?
     * (This close() runs also after the create-user dialog, via:
     * closeDialog('CloseAllLoginDialogs'))
    getSetCookie('dwCoIsInLoginWindow', null);
    getSetCookie('TyCoAvoidCookies', null);
     */
      const hasSid = me_hasSid();
      // + "[x] Log out from all devices"  — if there're other sessions as well
const securityPathSeg = 'security';
    const securityPath = prefsPathSlash + securityPathSeg;
      Route({ path: '(.*)/' + securityPathSeg, exact: true, render: () => SecurityPrefsTab(childProps) }),
              isGroupGuestOrBuiltIn ? null : LiNavLink({
                  to: securityPath, className: 'e_UP_Prf_Nav_SecL' }, t.upp.Security),

const SecurityPrefsTab = React.createFactory<any>(function(props: {
        user: UserInclDetails, store: Store }) {

  const store = props.store;
  const user = props.user;

  const [sessionsOrNull, setSessions] = React.useState<Session[] | N>(null);

  // Break out hook? [my_cur_id]
  const me = store.me;
  const myIdRef = React.useRef(me.id);

  React.useEffect(() => {
    myIdRef.current = me.id;
    listPatsSessions();
    return () => myIdRef.current = null;
  }, []);//, [me.id, user.id]);

  function listPatsSessions() {
    // If one is admin, then, `me` can be !== `user`.
    Server.listSessions(user.id, (resp: ListSessionsResponse) => {
      if (myIdRef.current !== me.id) return;
      // Show most recent first.
      const sessionsByTime = [...resp.sessions].sort(
              function(a,b ) { return b.createdAt - a.createdAt; });
      setSessions(sessionsByTime);
    });
  }

  if (!sessionsOrNull)
    return r.p({}, t.Loading);

  const sessions: Session[] = sessionsOrNull;
  let numActive = 0;

  const sessionItems = sessions.map((session: Session) => {
    if (!session.deletedAt && !session.expiredAt) numActive += 1;
    return r.li({ key: session.createdAt },
        SessionInfo(session, endSession, me));
  });

  function endSession(ps: { session?: Session, all?: true }) {
    const sessionsStartedAt = ps.session && [ps.session.createdAt];
    Server.terminateSessions({ forPatId: user.id, sessionsStartedAt, all: ps.all },
            (resp: TerminateSessionsResponse) => {
      if (myIdRef.current !== me.id) return;
      // Is sorted by time already, see sort(..) above.
      const sessionsAfter = arr_replaceMany(sessions,
              resp.terminatedSessions, (s: Session) => s.createdAt);
      setSessions(sessionsAfter);
    });
  }

  const isMyOnlySession = me.id === user.id && numActive === 1;

  const logOutEverywhereBtn = !numActive || isMyOnlySession ? null :
      Button({ className: 'c_SessL_EndAllB', onClick: () => endSession({ all: true })},
          "Log out everywhere (but not here)");   // I18N

  return (
    r.div({},
      // Later: "You're logged in on these devices:"
      // Or: "You are logged in on these devices, or were recently:"
      // Or: "Active or recently active devices:"  ?
      r.h2({}, "Active sessions:"),  // I18N
      r.ol({ className: 'c_SessL' }, sessionItems),
      sessionItems.length ? null : r.p({}, "None"), // I18N
      logOutEverywhereBtn,
      ));
});


function SessionInfo(session: Session, endSessionFn: (ps: { session: Session }) => V, me: Me) {
  const createdAt: St = new Date(session.createdAt).toISOString();
  let activeOrEnded: St;

  let deletedAt = session.deletedAt;
  let expiredAt = session.expiredAt;
  if (deletedAt && expiredAt) {
    if (deletedAt <= expiredAt) expiredAt = null;
    else deletedAt = null;
  }

  const isCurrent = me.mySidPart1 !== session.part1 ? '' :
          " — this session, here";

  let terminateBtn: RElm | U;
  let activeOrEndedClass = 'c_SessL_Sess-Ended';

  if (deletedAt) {
    activeOrEnded = " — got deleted at " + new Date(deletedAt).toISOString();
  }
  else if (expiredAt) {
    activeOrEnded = " — expired at " + new Date(expiredAt).toISOString();
  }
  else {
    activeOrEnded = isCurrent ? '' : " — currently active";
    activeOrEndedClass = 'c_SessL_Sess-Active';

    // Skip logout button for the current session. It's better if one stays logged
    // in and can see that the relevant sessions got terminated properly. And
    // thereafter click Log Out in one's username menu. (Or?)
    terminateBtn = me.mySidPart1 === session.part1 ? null :
          Button({ className: 'c_SessL_Sess_EndB',
              onClick: () => endSessionFn({ session }) }, "Log out");  // I18N
  }

  let debugJson = null;
  // @ifdef DEBUG
  debugJson = r.pre({}, JSON.stringify(session, undefined, 3));
  // @endif

  return r.div({ className: 'c_SessL_Sess ' + activeOrEndedClass },
      r.span({}, "Session started at " + createdAt + isCurrent + activeOrEnded),
      debugJson,
      terminateBtn);
}



    // Or if our session just got terminated from another device?
    // To try to make this happen, see /^sessions/ in tests-map.txt.
    dieIf(!anyMe, 'Server.loadMyself() returned me = null [TyE4032SMH57]');
    // Maybe?: if (!anyMe) return;
    // Or?: if (!anyMe) logoutClientSideOnly()

/// Not currently in use? But maybe call this one instead of
/// Server.logoutServerAndClientSide() drectly?
  Server.deleteTempSessId();  // [is_logging_out]
/// Returns parts 1 and 2 of any current session id, maybe 3. (Parts 4 and 5 are HttpOnly
/// cookies, not accessible here.)
///
export function getCurSid12Maybe3(): St | N {  // [ts_authn_modl]
  const store: Store = debiki2.ReactStore.allData();
  const cookieName =
          debiki2.store_isFeatFlagOn(store, 'ffUseNewSid') ? 'TyCoSid123' : 'dwCoSid';
  let sid = getSetCookie(cookieName);
  if (!sid) {
    // Cannot use store.me.mySidPart1 — we might not yet have loaded
    // the current user from the server; store.me might be stale.
    const typs: PageSession = getMainWin().typs;
    // This might not include part 3 (won't, if we're in an embedded comments
    // iframe, and didn't login or resume via a popup win directly against the
    // server so we could access cookie TyCoSid123, which includes part 3).
    sid = typs.weakSessionId;
  }
  return sid || null;
}


  // Need not delete store.me.mySidPart1 — we'll reload the page anyway. [is_logging_out]
    getSetCookie('TyCoSid123', null);
export function loadEmailAddressesAndLoginMethods(userId: UserId, onOk: UserAcctRespHandler,
          onErr?: (resp: A) => V) {
  get(`/-/load-email-addrs-login-methods?userId=${userId}`, onOk, onErr);
export function addEmailAddresses(userId: UserId, emailAddress: St, onOk: UserAcctRespHandler,
        onErr: (resp: A) => V) {
  postJsonSuccess('/-/add-email-address', onOk, onErr, { userId, emailAddress });
export function suspendUser(userId: UserId, numDays: Nr, reason: St, onOk: () => V,
        onErr: (resp: A) => V) {
  postJsonSuccess('/-/suspend-user', onOk, onErr, {
    // onErr(() => send 'failedToLogin' to parent frame)  [forget_sid12]
}


export function listSessions(patId: PatId, onOk: (resp: ListSessionsResponse) => V,
        onErr: () => V) {
  get(`/-/list-sessions?patId=${patId}`, onOk, onErr);
}


export function terminateSessions(
        ps: { forPatId: PatId, sessionsStartedAt?: WhenMs[], all?: true },
        onOk: (response: TerminateSessionsResponse) => V, onErr: () => V) {
  postJsonSuccess(`/-/terminate-sessions`, onOk, ps, onErr);
        // Reply 'failedToLogin' if login failed  [forget_sid12]
      if (!me_hasSid()) {
            `No session cookie, no typs.weakSessionId. ` +
    case 'logoutServerAndClientSide':
      Server.logoutServerAndClientSide();
      break;
        // We've remembered any weakSessionId already, see [5028KTDN306].
/// I can haz session id?
///
export function me_hasSid(): Bo {
  return !!(
        getSetCookie('dwCoSid') ||        // old
        getSetCookie('TyCoSid123') ||     // new, better  [btr_sid]
        getMainWin().typs.weakSessionId); // if cookies don't work
}


  weakSessionId?: St;  // RENAME to sid12Maybe3 ?
/// Either compares two items (if function.length === 2) or compares
/// a field value (if length === 1).
type ArrItemIsSameFn<Item> = ((a: Item, b: Item) => Bo) | ((it: Item) => any);

  // This is not the whole session id — it's the first 16 chars only [sid_part1];
  // the remaining parts have (a lot) more entropy than necessary.
  mySidPart1?: St | N;
interface Session {
  patId: PatId;
  createdAt: WhenMs;
  version: Nr,
  startHeaders: { [name: St]: St };
  part1: St;
  deletedAt?: WhenMs;
  expiredAt?: WhenMs;
}


interface ListSessionsResponse {
  sessions: Session[];
}


interface TerminateSessionsResponse {
  terminatedSessions: Session[];
}


  const offFlag = '0' + featureFlag;
  const isOn = _.includes(store.siteFeatureFlags, featureFlag) ||
  const isOff = _.includes(store.siteFeatureFlags, offFlag) ||
         _.includes(store.serverFeatureFlags, offFlag);
  return isOn && !isOff;   // [ff_on_off]
declare function getSetCookie(cookieName: St, value?: St, options?: Ay): St | Nl;
    RENAME to arr_groupByKeepOne ?
export function arr_replaceMany<Item>(
        arr: Item[], newerItems: Item[], isSame: ArrItemIsSameFn<Item>): Item[] {
  const newArr = [...arr];
  arr_replaceManyInPl(newArr, newerItems, isSame);
  return newArr;
}


export function arr_replaceManyInPl<Item>(
        arr: Item[], newerItems: Item[], isSame: ArrItemIsSameFn<Item>) {
  for (const newerItem of newerItems) {
    arr_replaceOneInPl(arr, newerItem, isSame);
  }
}


export function arr_replaceOneInPl<Item>(
        arr: Item[], newerItem: Item, isSame: ArrItemIsSameFn<Item>) {
  const fnLen = isSame.length;
  // @ifdef DEBUG
  dieIf(fnLen < 1 || 2 < fnLen, 'TyE60MEPWH32');
  // @endif

  const getFieldVal = fnLen === 1 ? isSame as ((item: Item) => A) : undefined;
  const newerFieldVal = getFieldVal && getFieldVal(newerItem);

  const ix = _.findIndex(arr, function(oldItem) {
    if (getFieldVal) return getFieldVal(oldItem) === newerFieldVal;
    else return isSame(oldItem, newerItem);
  });

  if (ix >= 0) {
    arr[ix] = newerItem;
  }
}


// REFACTOR use arr_replaceOneInPl & x => x.id  instead.
// + add an IfNotFound.ThenDie/ThenAppend/ThenIgnore param? Default to ThenDie?
  function arr_replaceMany<Item>(arr: Item[],
        newerItems: Item[], isSame: ArrItemIsSameFn<Item>): Item[];

  function arr_replaceManyInPl<Item>(arr: Item[],
        newerItems: Item[], isSame: ArrItemIsSameFn<Item>);

  function arr_replaceOneInPl<Item>(arr: Item[],
        newerItem: Item, isSame: ArrItemIsSameFn<Item>);
  function me_hasSid(): Bo;
/// <reference path="oop-methods.ts" />


  const store: Store = debiki2.ReactStore.allData();
  const me: Myself = store.me;

  // New style session id:  [btr_sid]
  // ------------------------------------------
  if (debiki2.store_isFeatFlagOn(store, 'ffUseNewSid')) {
    const sidParts123 = debiki2.Server.getCurSid12Maybe3();
    const stillTheSameSid = sidParts123
          ? me.mySidPart1 && sidParts123.startsWith(me.mySidPart1)
          : !me.mySidPart1;
    if (!stillTheSameSid) {
      if (sidParts123) {
        // The human has logged in.
        if (me.mySidPart1) {
          // The human was already logged in. Then it's good to reload the page so
          // stuff related to the previous user, disappears from the browser memory.
          location.reload();
        }
        else {
          // The human wasn't logged in. We can load the human's data without page reload.
          debiki2.ReactActions.loadMyself();
        }
      }
      else {
        // The human was logged in, but has now logged out. (This reloads the
        // page, or loads a new page, and forgets all in-mem state.)
        debiki2.ReactActions.logoutClientSideOnly();
      }
    }
    return;
  }

  // Old style session id:
  // ------------------------------------------
  if (me.isLoggedIn) {
      if (me.id !== parseInt(newUserIdString)) {

    Security: string;
// For the user to stay logged in, Ty can store not the whole but parts 1 and 2 of
// the session id in localStorage, if 3rd party cookies disabled.
// See docs/ty-security.adoc for more about sessions and parts 1, 2, 3, 4 and 5.
// [NOCOOKIES] Parts 1 and 2 don't let the user do much more than accessing and
// posting blog comments, and moderating blog comments if hen's a moderator
// (but won't give access to, for example, the admin area).
// Some more things to do: [restrict_sid_part_2_more].  [sid_part_3]
  window.addEventListener('storage', onLocalStorageChanged);
let curSessItemInStorage: St | Nl = null;

function onLocalStorageChanged() {
  // Maybe we logged out in another browser tab?
  /* First fix: [forget_sid12]
  const maybeNewSessItem: St | Nl = theStorage.getItem('talkyardSession');
  if (curSessItemInStorage !== maybeNewSessItem) {
    if (maybeNewSessItem) {
      sendToComments(['resumeWeakSession', maybeNewSessItem]);
    }
    else {
      sendToComments(['logoutServerAndClientSide', null]);
      sendToEditor(['logoutClientSideOnly', null]);
    }
    curSessItemInStorage = maybeNewSessItem;
  } */
}

          // Skip this hereafter?! [btr_sid] Do afterwards instead, if is now invalid.
          // Because otherwise we'd get logged out in other tabs — they listen to localStorage.
          // The iframe can send back a 'failedToLogin' instead?  [forget_sid12]
          curSessItemInStorage = null;
      // @ifdef DEBUG
      // Here, session id part 3 must not be included — it must not be seen
      // by the embedding website (only by code directly on the Talkyard domain).
      // So, the length should be:  SidLengthCharsPart1 + SidLengthCharsPart2 = 16 + 24
      // for the new fancy sessions. Whilst the old silly sids include a '.' dot.
      if (eventData.weakSessionId && eventData.weakSessionId.length !== 16 + 24
            && eventData.weakSessionId.indexOf('.') === -1)
        throw Error(`tySid12 should be 16 + 24 = ${16 + 24} chars but is ${
                eventData.weakSessionId.length} chars [TyEBADSID12LEN]`);
      // @endif
          curSessItemInStorage = JSON.stringify(item);
          theStorage.setItem('talkyardSession', curSessItemInStorage);

          // ! Need a 'failedToLogin' if login failed  [forget_sid12]
        curSessItemInStorage = null;
type V  = void; // ... hmm but really really not often. Let's use 'Va' for such templ params?
type A = any;   // Why not A! Let's use 'A'.
GET   /-/list-sessions          talkyard.server.sess.SessionController.listSessions(patId: Int)
POST  /-/terminate-sessions     talkyard.server.sess.SessionController.terminateSessions
# Maybe move /-/logout to SessionController?

[gdpr]
New table: sessions_t, impl fn deletePersonalDataFromOldSessions().  [sess_deleted_ix]

see: SessionType.AutoTokenSiteCustomSso, and: SidOk(TySession.ApiSecretPart12, ..)
Also see: [weaksid]. [which_ws_session]
Add [sess_deleted_ix]
And [btr_sid].
Missing:  [forget_sid12] [restrict_sid_part_2_more] [bump_sid_last_use]
[ios_itp]
Trigger happy tracking prevention produces problems.
Maybe maybe Safari [ios_itp] will sometimes mistake email verification links
or SSO url params, for being click trackers — e.g. Talkyard's
#talkyardOneTimeLoginSecret=... hash fragment param. See:
https://webkit.org/blog/9521/intelligent-tracking-prevention-2-3/
— then, Safari would delete the Ty session parts 1+2 from localStorage,
after a day.
https://www.cookiestatus.com/safari/
`reidr` means "Re-Enter Iframe in case you just Debug-Reloaded the page".
sessions:
  list one's active sessions,
  terminate one's other sessions:
            - d.sessions-logout-elsewhere.4br.ts  TyTESESLGOSELF
  staff can terminate others' sessions:
            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR
  mods cannot terminate admins sessions:
            - TESTS_MISSING  TyTESESTERMADM
            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR  TyEADMINSESS_
  require session part 4,
  a stolen embdding storage sid part 1+2 is pretty useless:
            - embcom.sessions-emb-sess-cannot-moderate.3br  TyTEECSESS0MOD
  require part 4 HttpOnly for admin stuff:
            - TESTS_MISSING
  require part 4 HttpOnly for non-embedded comments stuff:
            - TESTS_MISSING, and partly UNIMPL
  part 123 gone, client side offline logout:
            - sso-test  TyT4ABKRW0268.TyTESESS123GONE
            - TESTS_MISSING could add tests for this specifically
  part 123 bad,
  part 4 or 5 bad or gone:
            - TESTS_MISSING  TyTSESSHALFBAD
  sorted by recently active first, on one's user page:
            - TESTS_MISSING
  session terminated if user suspended or deleted:  [.del_susp_usr]
            - TESTS_MISSING


  from page to one's active sessions list:
            - d.sessions-logout-elsewhere.4br  TyTESESLGOSELF
deleted users:  [.del_susp_usr]
            - SEC_TEST_MISSING
  terminate someone else's sessions:
            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR

  no more WebSocket messages,
    if account deleted,
    or if suspended,
    or session terminated:
          - SEC_TESTS_MISSING  TyTE0MOREWSMSG
  const skip2And3Browsers = ['!.2br', '!.3br', '!.4br'];  // and skip 4 as well
  // ----- 4 browsers

  // Just these, currently:
  // d.sessions-logout-elsewhere.4br  TyTESESLGOSELF
  // d.sessions-staff-logout-others.4br  TyTESESLGOOTR

  next = ['.4br', ...skipEmbAndAlways];

  withSpecsMatching(next, (specs: St[]): ExitCode => {
    return runWdioInForeground(specs, '--4browsers');   // UNTESTED
  });


  $r s/wdio-7 --only d.sessions-logout-elsewhere.4br --cd -i $args
  $r s/wdio-7 --only d.sessions-staff-logout-others.4br --cd -i $args

  # Stealing an embedded session id
  $r s/wdio-7     --only embcom.sessions-emb-sess-cannot-moderate.3br --cd -i $args


      // Maybe better end any sessions — although aren't any.
      dao.terminateSessions(forPatId = member1.id, all = true)

/// <reference path="../test-types.ts"/>

import * as _ from 'lodash';
import assert from '../utils/ty-assert';
import server from '../utils/server';
import { buildSite } from '../utils/site-builder';
import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';

let brA: TyE2eTestBrowser;
let brB: TyE2eTestBrowser;
let brC: TyE2eTestBrowser;
let brD: TyE2eTestBrowser;
let owen: Member;
let owen_brA: TyE2eTestBrowser;
let owen_brB: TyE2eTestBrowser;
let owen_brC: TyE2eTestBrowser;
let maja: Member;
let maja_brD: TyE2eTestBrowser;

let site: IdAddress;
let forum: TwoCatsTestForum;



describe(`d.sessions-logout-elsewhere.4br  TyTESESLGOSELF`, () => {

  it(`Construct site`, async () => {
    const builder = buildSite();
    forum = builder.addTwoCatsForum({
      title: "Some E2E Test",
      members: ['owen', 'maja'],
    });

    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');
    brD = new TyE2eTestBrowser(wdioBrowserD, 'brD');

    owen = forum.members.owen;
    owen_brA = brA;
    owen_brB = brB;
    owen_brC = brC;

    maja = forum.members.maja;
    maja_brD = brD;

    assert.refEq(builder.getSite(), forum.siteData);
  });

  it(`Import site`, async () => {
    site = server.importSiteData(forum.siteData);
    server.skipRateLimits(site.id);
  });


  it(`Owen logs in to the admin area, in browser A ...`, async () => {
    await owen_brA.adminArea.settings.login.goHere(site.origin, { loginAs: owen });
  });
  it(`... and to the forum, in browser B`, async () => {
    await owen_brB.go2(site.origin);
    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
  });
  it(`... and C`, async () => {
    await owen_brC.go2(site.origin);
    await owen_brC.complex.loginWithPasswordViaTopbar(owen);
  });


  it(`Maja logs in, in browser D`, async () => {
    await maja_brD.go2(site.origin);
    await maja_brD.complex.loginWithPasswordViaTopbar(maja);
  });


  it(`Owen starts editing admin settings in browser A, without saving`, async () => {
    await owen_brA.adminArea.settings.login.setApproveUsers(true);
  });
  it(`... and starts composing a new topic in browser B`, async () => {
    await owen_brB.forumButtons.clickCreateTopic();
    await owen_brB.editor.editTitle("Title");
    await owen_brB.editor.editText("Text");
  });


  // ----- List active sessions

  it(`In browser C, Owen navigates to his profile page`, async () => {
    await owen_brC.topbar.clickGoToProfile();
  });
  it(`... the active sessions list`, async () => {
    await owen_brC.userProfilePage.tabs.switchToPreferences();
    await owen_brC.userProfilePage.preferences.tabs.switchToSecurity();
  });

  it(`Owen sees 3 active sessions`, async () => {
    const counts = await owen_brC.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 3, numEnded: 0 });
  });


  // ----- Terminate all one's other sessions

  it(`Owen treminates all sessions except for browser C`, async () => {
    await owen_brC.userProfilePage.preferences.security.terminateAllSessions();
  });

  it(`... now he sees one active session, and 2 terminated sessions`, async () => {
    // It takes a while for the server to reply, and the UI to update.
    await owen_brC.userProfilePage.preferences.security.waitForSessions({
            numActive: 1, numEnded: 2 });
  });
  it(`... after page reload ...`, async () => {
    await owen_brC.refresh2();
    await owen_brC.userProfilePage.preferences.security.waitForLoaded();
  });
  it(`... the terminated sessions are gone — terminated sessions aren't shown`, async () => {
    const counts = await owen_brC.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 1, numEnded: 0 });
  });


  // ----- Cannot use terminated sessions

  it(`In browser A, Owen tries to save the admin settings`, async () => {
    await owen_brA.adminArea.settings.clickSaveAll({ willFail: true });
  });
  it(`... there's an error: Owen isn't logged in as admin (in brA)`, async () => {
    await owen_brA.serverErrorDialog.waitForNotLoggedInAsAdminError();
  });
  it(`... after page refresh, he sees the login dialog (in brA)`, async () => {
    await owen_brA.refresh2();
    await owen_brA.loginDialog.waitAssertFullScreen();
  });

  it(`In browser B, Owen tries to save the topic`, async () => {
    await owen_brB.editor.save();
  });
  it(`... there's a not-logged-in error in brB`, async () => {
    await owen_brB.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after page refresh, he sees he's logged out in brB`, async () => {
    await owen_brB.refresh2();
    await owen_brB.topbar.waitUntilLoginButtonVisible();
  });


  // ----- Login again

  it(`Owen logs in again, in browser B`, async () => {
    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
  });
  it(`... and logs in to his sessions list page, in browser A ...`, async () => {
    await owen_brA.userProfilePage.preferences.security.goHere(owen.username, { loginAs: owen });
  });

  it(`There are again 3 active sessions`, async () => {
    const counts = await owen_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 3, numEnded: 0 });
  });


  // ----- Session sort order

  it(`... Owen's brA session is in the middle   TESTS_MISSING
              because sessions are sorted by created-at currently`, async () => {
  });


  // ----- Terminate a specific session

  it(`Owen terminates the oldest session, the one in brC`, async () => {
    await owen_brA.userProfilePage.preferences.security.terminateOldestSession();
  });
  it(`... thereafter, 2 active and 1 teminated sessions listed`, async () => {
    await owen_brA.userProfilePage.preferences.security.waitForSessions({
            numActive: 2, numEnded: 1 });
  });
  it(`ttt: There're 2 terminate-session buttons: One to terminate "all", and one
              to terminate the one and only other session`, async () => {
    const n = await owen_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 2);
  });

  it(`Now, in brC, Owen tries to switch to the Drafts Etc tab`, async () => {
    await owen_brC.userProfilePage.tabs.switchToDraftsEtc({ willFail: true });
  });
  it(`... there's an error: He's no longer logged in, in that browser`, async () => {
    await owen_brC.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after reload ...`, async () => {
    await owen_brC.refresh2();
  });
  it(`... the login button appears`, async () => {
    await owen_brC.topbar.waitUntilLoginButtonVisible();
  });


  // ----- One's other sessions still work

  it(`But in brB, Owen is still logged in: He can submit a topic`, async () => {
    await owen_brB.complex.createAndSaveTopic({ title: "Owen's Topic", body: "Text." });
  });
  it(`... and after reload ...`, async () => {
    await owen_brB.refresh2();
  });
  it(`... he's still logged in — sees his username menu`, async () => {
    assert.eq(await owen_brB.topbar.getMyUsername(), owen.username);
  });


  // ----- Other users not affected

  it(`Maja is still logged in — she can post a topic`, async () => {
    await maja_brD.complex.createAndSaveTopic({ title: "Maja's Topic", body: "Text." });
  });
  it(`... and reload the page`, async () => {
    await maja_brD.refresh2();
  });
  it(`... she still sees her username — she's still logged in`, async () => {
    await maja_brD.topbar.assertMyUsernameMatches(maja.username);
  });


  // ----- Session disappears from list, after logging out in other browser

  it(`Owen logs out in browser B`, async () => {
    await owen_brB.topbar.clickLogout();
  });

  it(`... and reloads the session list in browser A`, async () => {
    await owen_brA.refresh2();
  });
  it(`... now there's just one active session — his current session, brA`, async () => {
    const counts = await owen_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 1, numEnded: 0 });
  });

  it(`There's no end-session button — cannot end one's current session here`, async () => {
    const n = await owen_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 0);
  });

});
/// <reference path="../test-types.ts"/>

import * as _ from 'lodash';
import assert from '../utils/ty-assert';
import server from '../utils/server';
import { buildSite } from '../utils/site-builder';
import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';
import settings from '../utils/settings';

let brA: TyE2eTestBrowser;
let brB: TyE2eTestBrowser;
let brC: TyE2eTestBrowser;
let brD: TyE2eTestBrowser;
let owen: Member;
let owen_brB: TyE2eTestBrowser;
let mons: Member;
let mons_brA: TyE2eTestBrowser;
let maja: Member;
let maja_brB: TyE2eTestBrowser;
let mallory: Member;
let mallory_brC: TyE2eTestBrowser;
let mallory_brD: TyE2eTestBrowser;

let site: IdAddress;
let forum: TwoCatsTestForum;



describe(`d.sessions-staff-logout-others.4br  TyTESESLGOOTR`, () => {

  it(`Construct site`, async () => {
    const builder = buildSite();
    forum = builder.addTwoCatsForum({
      title: "Some E2E Test",
      members: ['owen', 'maja', 'mons', 'mallory'],
    });

    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');
    brD = new TyE2eTestBrowser(wdioBrowserD, 'brD');

    owen = forum.members.owen;
    owen_brB = brB;

    mons = forum.members.mons;
    mons_brA = brA;

    maja = forum.members.maja;
    maja_brB = brB;

    mallory = forum.members.mallory;
    mallory_brC = brC;
    mallory_brD = brD;

    assert.refEq(builder.getSite(), forum.siteData);
  });

  it(`Import site`, async () => {
    site = server.importSiteData(forum.siteData);
    server.skipRateLimits(site.id);
  });


  // ----- (Maja and Mallory log in)

  it(`Maja logs in, in browser B`, async () => {
    await maja_brB.go2(site.origin);
    await maja_brB.complex.loginWithPasswordViaTopbar(maja);
  });

  it(`Mallory too, in browser C ...`, async () => {
    await mallory_brC.go2(site.origin);
    await mallory_brC.complex.loginWithPasswordViaTopbar(mallory);
  });

  it(`Mallory logs in in browser D too`, async () => {
    await mallory_brD.userProfilePage.preferences.goHere(
            mallory.username, { origin: site.origin });
    await mallory_brD.complex.loginWithPasswordViaTopbar(mallory);
  });
  it(`... starts editing his name`, async () => {
    await mallory_brD.userProfilePage.preferences.setFullName('Admin of Admins');
  });


  // ----- Cannot view someone else's sessions (unless one is mod)

  it(`Mallory types the URL path to Maja's Security tab, in his other browser, C`, async () => {
    await mallory_brC.userProfilePage.preferences.security.goHere(maja);
  });

  it(`... there's nothing here to see — cannot view others' sessions`, async () => {
    await mallory_brC.userProfilePage.waitForTabsVisible();
    assert.not(await mallory_brC.userProfilePage.tabs.isPreferencesTabDisplayed());
    assert.not(await mallory_brC.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
  });

  it(`Mallory returns to the forum topic list instead (in browser C)`, async () => {
    await mallory_brC.topbar.clickBack();
  });


  // ----- A mod terminates a specific session

  it(`Moderator Mons goes to Mallory's profile page`, async () => {
    await mons_brA.userProfilePage.preferences.goHere(mallory.username, { origin: site.origin });
    await mons_brA.complex.loginWithPasswordViaTopbar(mons);
  });
  it(`... sees the Preferences tab  ttt`, async () => {
    await mons_brA.userProfilePage.waitForTabsVisible();
    // ttt — see assert.not(..) above.
    assert.that(await mons_brA.userProfilePage.tabs.isPreferencesTabDisplayed());
  });
  it(`... and the About and Security tab`, async () => {
    await mons_brA.userProfilePage.preferences.tabs.waitForAboutTabDisplayed();
    // ttt — see assert.not(..) above.
    assert.that(await mons_brA.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
  });
  it(`... switches to the Security tab`, async () => {
    await mons_brA.userProfilePage.preferences.tabs.switchToSecurity();
  });

  it(`Mons sees Mallory's 2 sessions`, async () => {
    const num = await mons_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(num, { numActive: 2, numEnded: 0 });
  });

  it(`Mons terminates Mallory's newest session (that's browser D)`, async () => {
    await mons_brA.userProfilePage.preferences.security.terminateNewestSession();
  });

  it(`... afterwards, he sees one active and one terminated session`, async () => {
    await mons_brA.userProfilePage.preferences.security.waitForSessions({
            numActive: 1, numEnded: 1 });
  });


  // ----- Cannot use a terminated session

  it(`Mallory tries to save his username (in browser D)`, async () => {
    await mallory_brD.userProfilePage.preferences.clickSave();
  });
  it(`... there's an error: Mallory isn't logged in (now in brD)`, async () => {
    await mallory_brD.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after page refresh ...`, async () => {
    await mallory_brD.refresh2();
  });
  it(`... he sees the login button (in brD)`, async () => {
    await mallory_brD.me.waitUntilKnowsNotLoggedIn();
    await mallory_brD.topbar.waitUntilLoginButtonVisible();  // ttt
  });

  it(`In browser C, Mallory is still logged in — he posts a new topic`, async () => {
    await mallory_brC.complex.createAndSaveTopic({
          title: "Mallory's Place", body: "This is my palace, I, the Admin of Admins" });
  });


  // ----- (Logging in again)

  it(`Mallory logs in again in browser D`, async () => {
    await mallory_brD.complex.loginWithPasswordViaTopbar(mallory);
  });


  // ----- A mod terminates all a user's sessions

  it(`Mons refreshes the sessions list`, async () => {
    await mons_brA.refresh2();
  });

  it(`... sees Mallory is back, two active sessions`, async () => {
    const num = await mons_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(num, { numActive: 2, numEnded: 0 });
  });

  it(`... there're terminate buttons for all sessions, plus an all-in-one-click`, async () => {
    const n = await mons_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 2 + 1);
  });

  it(`Mons terminates all Mallory's sessions`, async () => {
    await mons_brA.userProfilePage.preferences.security.terminateAllSessions();
  });

  it(`... afterwards, no more active sessions, 2 terminated`, async () => {
    await mons_brA.userProfilePage.preferences.security.waitForSessions({
            numActive: 0, numEnded: 2 });
  });

  it(`... no more terminate buttons`, async () => {
    const n = await mons_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 0);
  });


  // ----- Cannot use the terminated sessions

  it(`Mallory tries to reply to and agree with himself (in brC)`, async () => {
    await mallory_brC.topic.clickReplyToOrigPost();
  });
  it(`... but there's a not-logged-in error`, async () => {
    await mallory_brC.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after page refresh ...`, async () => {
    await mallory_brC.refresh2();
  });
  it(`... he sees he's logged out, sees the login button`, async () => {
    await mallory_brC.me.waitUntilKnowsNotLoggedIn();
    await mallory_brC.topbar.waitUntilLoginButtonVisible();  // ttt
  });

  it(`He's logged out in the other browser, brD, too`, async () => {
    await mallory_brD.refresh2();
    await mallory_brD.me.waitUntilKnowsNotLoggedIn();
  });


  // ----- Other users not affected

  it(`Maja is still logged in: She can submit a topic`, async () => {
    await maja_brB.complex.createAndSaveTopic({ title: "Important about cream",
            body: "Is milk cream the healthiest dinner, with or without sugar" });
  });

  it(`... and after reload ...`, async () => {
    await maja_brB.refresh2();
  });

  it(`... she's still logged in — sees her username menu`, async () => {
    assert.eq(await maja_brB.topbar.getMyUsername(), maja.username);
  });


  // ----- Mods cannot terminate admins' sessions

  it(`Owen logs in`, async () => {
    await maja_brB.topbar.clickLogout();
    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
  });

  it(`Mons goes to Owen's profile page, the Preferences tab`, async () => {
    await mons_brA.userProfilePage.preferences.goHere(owen.username);
  });

  it(`... sees the About tab to the left ...`, async () => {
    await mons_brA.userProfilePage.preferences.tabs.waitForAboutTabDisplayed();
  });
  it(`... but there's no Security tab! Cannot view admin's sessions  UNIMPL TESTS_MISSING TyTESESTERMADM`, async () => {
    // hmm there is, but it won't work.
    //assert.not(await mons_brA.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
  });

  it(`Mons instead types the URL path to Owen's Security tab`, async () => {
    await mons_brA.userProfilePage.preferences.security.goHere(owen);
  });
  it(`... there's an error: he's not allowed to view an admin's sessions`, async () => {
    await mons_brA.serverErrorDialog.waitAndAssertTextMatches('TyEADMINSESS_');
    await mons_brA.serverErrorDialog.close();
  });


  // ----- Admins can terminate mods' sessions

  it(`Owen jumps to Mon's Security tab`, async () => {
    await owen_brB.userProfilePage.preferences.security.goHere(mons);
  });

  it(`... logs Mons out`, async () => {
    await owen_brB.userProfilePage.preferences.security.terminateAllSessions();
  });

  it(`... now there's 1 terminated session`, async () => {
    await owen_brB.userProfilePage.preferences.security.waitForSessions({
            numActive: 0, numEnded: 1 });
  });


  // ----- Session won't work afterwards

  it(`Mons tries to view invites Owen has sent`, async () => {
    await mons_brA.userProfilePage.switchToInvites({ willFail: true });
  });
  it(`... but there's a not-logged-in error`, async () => {
    await mons_brA.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... Mons reloads the page ...`, async () => {
    await mons_brA.refresh2();
  });
  it(`... Mons sees he's logged out, sees the login button`, async () => {
    await mons_brA.me.waitUntilKnowsNotLoggedIn();
    await mons_brA.topbar.waitUntilLoginButtonVisible();  // ttt
  });
  

});

      it(`Owen reloads the page, as per the instructions ...`, async () => {
      });

      it(`... needs to log in again — sessions_t curently not exported`, async () => {
        await owensBrowser.loginDialog.loginWithPassword(owen);
      });

      it(`... he's Owen again`, async () => {
/// <reference path="../test-types.ts"/>

import * as _ from 'lodash';
import assert from '../utils/ty-assert';
import * as fs from 'fs';
import server from '../utils/server';
import * as utils from '../utils/utils';
import { buildSite } from '../utils/site-builder';
import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';
import { logMessage, j2s, logBoring } from '../utils/log-and-die';
import c from '../test-constants';
import { IsWhere } from '../test-types';

let brA: TyE2eTestBrowser;
let brB: TyE2eTestBrowser;
let brC: TyE2eTestBrowser;
let owen: Member;
let owen_brA: TyE2eTestBrowser;
let alice: Member;
let alice_brC: TyE2eTestBrowser;
let mons: Member;
let mons_brA: TyE2eTestBrowser;
let maja: Member;
let maja_brA: TyE2eTestBrowser;
let mallory: Member;
let mallory_brB: TyE2eTestBrowser;

// Try to remember to update, if adding more users?
const majasUserId = 104;

const weakSidErrCode = 'TyEWEAKSID_';  // move to test constants?

const localHostname = 'comments-for-e2e-test-stealembs-localhost-8080';
const embeddingOrigin = 'http://e2e-test-stealembs.localhost:8080';
let embeddingPageSlug = 'emb-sess-stealer.html';
let embeddingPageUrl = embeddingOrigin + '/' + embeddingPageSlug;

let site: IdAddress;
let forum: TwoCatsTestForum;

let monsLocalStorageSessionJsonSt = '';

let stolenSidPart1And2WasMons = '';

let stolenSidPart1OnlyWasOwens = '';
let stolenSidPart1And2WasOwens = '';


describe(`embcom.sessions-emb-sess-cannot-moderate.3br  TyTEECSESS0MOD`, () => {

  it(`Construct site`, async () => {
    const builder = buildSite();
    forum = builder.addTwoCatsForum({
      title: "Emb Comments Sessions Cannot Moderate",
      members: ['owen', 'alice', 'mons', 'maja', 'mallory'],
    });

    builder.getSite().meta.localHostname = localHostname;
    builder.getSite().settings.allowEmbeddingFrom = embeddingOrigin;

    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');

    owen = forum.members.owen;
    owen_brA = brA;

    mons = forum.members.mons;
    mons_brA = brA;

    maja = forum.members.maja;
    maja_brA = brA;

    mallory = forum.members.mallory;
    mallory_brB = brB;

    alice = forum.members.alice;
    alice_brC = brC;

    assert.refEq(builder.getSite(), forum.siteData);
  });

  it(`Import site`, async () => {
    site = server.importSiteData(forum.siteData);
    server.skipRateLimits(site.id);
  });


  it(`Create embedding pages`, () => {
    const dir = 'target';
    fs.writeFileSync(`${dir}/${embeddingPageSlug}`, makeHtml('aaa', '#800'));
    function makeHtml(pageName: St, bgColor: St): St {
      return utils.makeEmbeddedCommentsHtml({
              pageName, discussionId: '', localHostname, bgColor});
    }
  });


  it(`Mons goes to a dangerous embedding page (in browser A)
            with an xss attack that lets Mallory steal session part 1 and 2`, async () => {
    await mons_brA.go2(embeddingPageUrl);
  });

  it(`... Mons logs in`, async () => {
    await mons_brA.complex.loginIfNeededViaMetabar(mons);
  });

  it(`Mallory steals the session via the xss attack`, async () => {
    monsLocalStorageSessionJsonSt = await mons_brA.execute(function() {
      return localStorage.getItem('talkyardSession');
    });

    stolenSidPart1And2WasMons = JSON.parse(monsLocalStorageSessionJsonSt).weakSessionId;

    logMessage(`Mallory stole Mons' session: "${monsLocalStorageSessionJsonSt}",
        part 1 + 2: "${stolenSidPart1And2WasMons}"`);
  });


  it(`Mallory goes to the embedding page, in his own browser B`, async () => {
    await mallory_brB.go2(embeddingPageUrl);
  });

  it(`... puts the stolen session in localStorage`, async () => {
    await mallory_brB.execute(function(s) {
      localStorage.setItem('talkyardSession', s);
    }, monsLocalStorageSessionJsonSt);
  });

  it(`... reloads`, async () => {
    await mallory_brB.refresh2();
  });

  it(`... now Mallory is logged in as Mons — but with session id parts 1+2 only`, async () => {
    await mallory_brB.switchToEmbeddedCommentsIrame();
    assert.eq(await mallory_brB.metabar.getMyUsernameInclAt(), '@' + mons.username);
  });

  it(`Bad 1, will work: Mallory posts a comment`, async () => {
    await mallory_brB.complex.replyToEmbeddingBlogPost(
            "Hi I'm Mons no I'm not yes_no_yes_you_guess");
  });

  let resp;

  it(`Bad 2, won't work: Mallory tries to suspend Maja`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded(); // reidr
    resp = await mallory_brB.hackServer.suspendUser(majasUserId);
  });
  it(`Bad 2: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 2: ... there's an error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });

  it(`Bad 3: Mallory then tries to add his own email addr to Mon's account`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mallory_brB.hackServer.addEmailAddresses("mallory@x.co");
  });
  it(`Bad 3: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 3: ... there's an error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });


  it(`Bad 4: Mallory tries to list Mons' email addresses`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mallory_brB.hackServer.loadEmailAddressesAndLoginMethods();
  });
  it(`Bad 4: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 4: ... there's another error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });


  it(`Good 4: Mons also cannot list his email addresses from inside the iframe`, async () => {
    await mons_brA.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mons_brA.hackServer.loadEmailAddressesAndLoginMethods();
  });
  it(`Good 4: ... the server says no to Mons too`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Good 4: ... Mons also gets an error dialog`, async () => {
    await mons_brA.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mons_brA.serverErrorDialog.close();
  });


  /*
  it(`Bad 5: Mallory tries to download Mons' personal data  TESTS_MISSING`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mallory_brB.hackServer.  ?? ();
  });
  it(`Bad 5: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 5: ... there's another error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });
  */

  let monsUserId: Nr | U;

  it(`Mallory remembers Mons' user id`, async () => {
    monsUserId = await mallory_brB.hackServer.getMyUserId();
    logBoring(`Mons' user id is: ${monsUserId}`);
  });



  it(`Mallory click-opens Mons' profile in a new tab`, async () => {
    await mallory_brB.metabar.openMyProfilePageInNewTab();
  });

  it(`... but in the new tab, he's not logged in — cookies missing`, async () => {
    await mallory_brB.swithToOtherTabOrWindow();
    await mallory_brB.me.waitUntilKnowsNotLoggedIn();
  });


  it(`Mons, though, can click his name ...`, async () => {
    await mons_brA.switchToEmbCommentsIframeIfNeeded();
    await mons_brA.metabar.openMyProfilePageInNewTab();
  });

  it(`... and in the new tab, he *is* logged in`, async () => {
    await mons_brA.swithToOtherTabOrWindow();
    await mons_brA.me.waitUntilLoggedIn();
    await mons_brA.topbar.assertMyUsernameMatches(mons.username);
  });

  it(`Mons checks his email addresses`, async () => {
    await mons_brA.userProfilePage.tabs.switchToPreferences();
    await mons_brA.userProfilePage.preferences.tabs.switchToAccount();
  });

  it(`... no new address has been added`, async () => {
    const adrs: St[] =
            await mons_brA.userProfilePage.preferences.emailsLogins.getAllEmailAddresses();
    assert.deepEq(adrs, [mons.emailAddress]);
  });


  it(`Mallory uses the stolen session parts 1+2, in the new tab`, async () => {
    await mallory_brB.hackServer.setSidPart12ViaJs(stolenSidPart1And2WasMons, { inCookie: true });
  });

  it(`... now Mallory has a cookie for session parts 1+2`, async () => {
    const sids = await mallory_brB.hackServer.getSids();
    logMessage(`getSids() says: ${j2s(sids)}`);
    assert.greaterThan(sids.sidCookiePart123.length, 30);
  });

  it(`Mallory reloads, tries to use the parts 1+2 cookie`, async () => {
    await mallory_brB.refresh2({ isWhere: IsWhere.External });
  });
  it(`... but there's an error: SID part 3 missing`, async () => {
    const source = await mallory_brB.getPageSource();
    assert.includes(source, weakSidErrCode);
  });


  let monsDataUrl: St | U;
  let monsContentUrl: St | U;

  it(`Mallory constructs links to Mons' personal data and Mons' posts`, async () => {
    monsDataUrl = mallory_brB.user.genDownloadPersonalDataUrl(monsUserId);
    monsContentUrl = mallory_brB.user.genDownloadPersonalContentUrl(monsUserId);
  });

  it(`Mallory then tries to fetch Mons' personal data`, async () => {
    await mallory_brB.go2(monsDataUrl, { willBeWhere: IsWhere.External });
  });
  it(`... this not allowed — session id part 3 missing`, async () => {
    const source = await mallory_brB.getPageSource();
    assert.includes(source, weakSidErrCode);
  });

  it(`Mons, though, can download his data — he has SID part 3 HttpOnly`, async () => {
    await mons_brA.go2(monsDataUrl, { willBeWhere: IsWhere.External });
  });
  it(`... his name and emai (among other things) are in the JSON`, async () => {
    const source = await mons_brA.getPageSource();
    assert.includes(source, mons.username);
    assert.includes(source, mons.emailAddress);
    assert.excludes(source, weakSidErrCode);
  });


  it(`Mallory tries to fetch Mon's posts`, async () => {
    await mallory_brB.go2(monsContentUrl, { willBeWhere: IsWhere.External });
  });
  it(`... also not allowed — session id part 3 missing`, async () => {
    const source = await mallory_brB.getPageSource();
    assert.includes(source, weakSidErrCode);
  });

  it(`Mons can download his posts — he has SID part 3 HttpOnly`, async () => {
    await mons_brA.go2(monsContentUrl, { willBeWhere: IsWhere.External });
  });
  it(`... Mallory's post, made with the stolen SID part 1+2, is in the JSON`, async () => {
    const source = await mons_brA.getPageSource();
    assert.includes(source, 'yes_no_yes_you_guess');
    assert.excludes(source, weakSidErrCode);
  });


  /* Maybe?
  it(`... but when Mallory tries to use the sid, the server clears all session cookies
            — it notices that part 3, HttpOnly, is missing`, async () => {
  });

  it(`The server also ends the session — since something is amiss`, async () => {
  });
  */

});

  const wdioBrowserD: WebdriverIOAsync.MultiRemoteBrowser | U;
  EmbCommentsIframe = 4,  // hmm could add an enum value for EmbSessionIframe
export type ServerSays = ServerSaysOk | ServerSaysError;

export interface ServerSaysOk {
  serverSaysOk: A;
}

export interface  ServerSaysError {
  serverSaysError: A;
}

if (args['4br'] || args['4browsers']) {
  settings.numBrowsers = 4;
}
if (args['5br'] || args['5browsers'] || args['6br'] || args['6browsers']) {
  die(`E2e tests with 5 or 6 browsers isn't supported. [TyE2MNYBROWSRS]`);
}
import { ServerSays } from '../test-types2';
interface SidFieldsParts {
  sidFieldParts12Maybe3?: St;
}

interface SidParts extends SidFieldsParts {
  sidCookiePart123?: St;
  sidCookiePart4?: St;
  sidCookiePart5?: St;
}

  constructor(aWdioBrowser: WebdriverIOAsync.Browser,
          name: 'brA' | 'brB' | 'brC' | 'brD' | 'brAll') {
    async executeAsync<T>(script: ((...args: any[]) => V), ...args: any[]): Pr<T> {
    user = {
      genDownloadPersonalDataUrl: (userId: PatId): St => {
        return '/-/download-personal-data?userId=' + userId;
      },

      genDownloadPersonalContentUrl: (userId: PatId): St => {
        return '/-/download-my-content?authorId=' + userId;
      },
    }

            waitForContent?: false, discId?: St, theresOnlyOne?: true,
            theSessionIdIframe?: true } = {}) {
      if (ps.theSessionIdIframe) {
        commentsIframeSelector = 'iframe#talkyard-session';
      }
      else if (this.#useCommentsIframe?.discussionId) {
    async waitAndClickLast(selector: St, ps: { needScroll?: Bo } = {}): Pr<ClickResult> {
      return await this.waitAndClickNth(selector, -1, ps);
    async waitAndClickNth(selector: St, n: Nr, ps: { needScroll?: Bo } = {}): Pr<ClickResult> {   // BUG will only scroll the 1st elem into view [05YKTDTH4]
      dieIf(n === 0, "n starts on 1, change from 0 to 1 please (or -1 to click the last item)");
      logWarningIf(n !== 1 && ps.needScroll !== false,
      tyAssert.ok(elems.length >= Math.abs(n),
            `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
      switchToInvites: async (ps: { willFail?: true } = {}) => {
        if (!ps.willFail) {
          await this.invitedUsersList.waitUntilLoaded();
        }
      tabs: {
        switchToDraftsEtc: async (ps: { willFail?: true } = {}) => {
          await this.waitAndClick('.e_UP_DrftsB');
          if (!ps.willFail) {
            await this.waitUntilLoadingOverlayGone();
            await this.userProfilePage.draftsEtc.waitUntilLoaded();
          }
        },

        isPreferencesTabDisplayed: async (): Pr<Bo> => {
          return await this.isDisplayed('#e2eUP_PrefsB');
        },
        switchToPreferences: async () => {
          await this.userProfilePage.clickGoToPreferences();
        },
      },

        tabs: {
          waitForAboutTabDisplayed: async (): Pr<Bo> => {
            return await this.waitForDisplayed('.s_UP_Prf_Nav_AbtL');
          },
          switchToAbout: async (ps: { willFail?: true } = {}) => {
            await this.waitAndClick('.s_UP_Prf_Nav_AbtL');
            if (!ps.willFail) {
              await this.waitForVisible('.e_UP_Prefs_FN');
            }
          },

          switchToAccount: async (ps: { /*willFail?: true*/ } = {}) => {
            await this.userProfilePage.preferences.switchToEmailsLogins();
          },

          isSecurityTabDisplayed: async (): Pr<Bo> => {
            return await this.isDisplayed('.e_UP_Prf_Nav_SecL');
          },
          switchToSecurity: async () => {
            await this.waitAndClick('.e_UP_Prf_Nav_SecL');
            await this.userProfilePage.preferences.security.waitForLoaded();
          },
        },

        switchToEmailsLogins: async () => {  // MOVE to tabs.switchToAccount() instead (above)
          // Break out fn:  waitForLoaded();  ?
        security: {
          goHere: async (viewWho: St | Member, ps: { isGroup?: true, origin?: St,
                    loginAs?: Member } = {}) => {
            const username = _.isString(viewWho) ? viewWho : viewWho.username;
            await this.userProfilePage._goHere(username, ps, '/preferences/security');
            if (ps.loginAs) {
              await this.complex.loginWithPasswordViaTopbar(ps.loginAs);
            }
          },

          waitForLoaded: async (): Pr<V> => {
            await this.waitForVisible('.c_SessL');  // or waitForExist? works if not logged in
          },

          waitForSessions: async (ps: { numActive: Nr, numEnded: Nr }): Pr<V> => {
            let numNow;
            await this.waitUntil(async () => {
              numNow = await this.userProfilePage.preferences.security.countSessions();
              return _.isEqual(numNow, ps);
            }, {
              message: () => `Waiting for ${j2s(ps)} sessions, currently: ${j2s(numNow)}`,
            })
          },

          countSessions: async (): Pr<{ numActive: Nr, numEnded: Nr }> => {
            const numEnded = await this.count('.c_SessL_Sess-Ended');
            const numActive = await this.count('.c_SessL_Sess-Active');
            return { numEnded, numActive };
          },

          numEndSessionButtonsVisible: async (): Pr<Nr> => {
            const num = await this.count('.c_SessL_Sess_EndB');
            return num + await this.count('.c_SessL_EndAllB');
          },

          terminateNewestSession: async (): Pr<V> => {
            await this.waitAndClickFirst('.c_SessL_Sess_EndB');
          },

          terminateOldestSession: async (): Pr<V> => {
            await this.scrollToBottom();
            await this.waitAndClickLast('.c_SessL_Sess_EndB', { needScroll: false });
          },

          terminateAllSessions: async (): Pr<V> => {
            await this.waitAndClick('.c_SessL_EndAllB');
          },
        },

          getAllEmailAddresses: async (): Pr<St[]> => {
            await this.waitForDisplayed('.s_UP_EmLg_EmL_It_Em');
            const elms = await this.$$('.s_UP_EmLg_EmL_It_Em');
            const adrs = [];
            for (const el of elms) {
              const text = await el.getText();
              adrs.push(text);
            }
            return adrs;
          },



  /// For hacking into Talkyard, doing weird things.
  ///
  hackServer = {
    setSidPart12ViaJs: async (value: St, ps: { inCookie?: true, inField?: true }): Pr<V> => {
      await this.execute(function(value, ps) {
        if (ps.inCookie) {
          console.debug(`Setting cookie TyCoSid123 = "${value}" ...`);
          window['getSetCookie']('TyCoSid123', value);
          console.debug(`Done.`);
        }
        if (ps.inField) {
          try {
            console.debug(`Setting getMainWin().typs.weakSessionId = "${value}" ...`);
            window['debiki2'].getMainWin().typs.weakSessionId = value;
            console.debug(`Done.`);
          }
          catch (ex) {
            console.error(ex);  // logs nothing?
          }
        }
      }, value, ps);
    },


    getSidPart12Maybe3ViaJs: async (ps: { inCookie?: true, inField?: true })
          : Pr<SidFieldsParts | U> => {
      dieIf(!!ps.inCookie == !!ps.inField, "TyE50MEPW24");
      const anyValue = await this.#br.execute(function(ps) {
        let result: SidFieldsParts | U;
        if (ps.inCookie) {
          die(`Unimpl: Getting cookie TyCoSid123...`);
          result = {
            // sidCookiePart123ViaJs: window['getSetCookie']('TyCoSid123'),
          };
          console.debug(`Done.`);
        }
        if (ps.inField) {
          try {
            console.debug(`Getting getMainWin().typs.weakSessionId...`);
            result = {
              sidFieldParts12Maybe3: window['debiki2'].getMainWin().typs.weakSessionId,
            };
            console.debug(`Done.`);
          }
          catch (ex) {
            console.error(ex);  // logs nothing?
          }
        }
        console.debug(`Returning: "${result}`);
        return result;
      }, ps);
      return anyValue;
    },

    getSids: async (): Pr<SidParts> => {
      const result: SidParts = await this.hackServer.getSidPart12Maybe3ViaJs({ inField: true });
      const sidCookies = await this.#br.getCookies(
              ['TyCoSid123', 'TyCoSid4', 'TyCoSid5']);
      for (const cookie of sidCookies) {
        const resultFieldName =
                cookie.name === 'TyCoSid123' ? 'sidCookiePart123' : (
                cookie.name === 'TyCoSid4' ? 'sidCookiePart4' : (
                cookie.name === 'TyCoSid5' ? 'sidCookiePart5' : die('TyE503MEGE2')));
        result[resultFieldName] = cookie.value;
      }
      return result;
    },

    getMyUserId: async (): Pr<PatId> => {
      return await this.execute(function() {
        const store = window['theStore'];
        return store.me.id;
      });
    },

    loadEmailAddressesAndLoginMethods: async (): Pr<ServerSays> => {
      const result: ServerSays = await this.executeAsync<ServerSays>(function(done) {
        const store = window['theStore'];
        const me = store.me;
        // Calls:  `/-/load-email-addrs-login-methods?userId=${userId}`
        window['debiki2'].Server.loadEmailAddressesAndLoginMethods(me.id, function(resp) {
          console.debug(`OK response: ${JSON.stringify(resp)}`);
          done({ serverSaysOk: resp });
        }, function(resp) {
          console.debug(`Error response: ${JSON.stringify(resp)}`);
          done({ serverSaysError: resp });
        });
      });
      console.debug(`Result: ${j2s(result)}`);
      return result;
    },

    addEmailAddresses: async (newAdr: St): Pr<ServerSays> => {
      const result: ServerSays = await this.executeAsync<ServerSays>(function(newAdr, done) {
        const store = window['theStore'];
        const me = store.me;
        // Calls:  /-/add-email-address
        // handled by:  UserController.addUserEmail().
        window['debiki2'].Server.addEmailAddresses(me.id, newAdr, function(resp) {
          console.debug(`OK response: ${JSON.stringify(resp)}`);
          done({ serverSaysOk: resp });
        }, function(resp) {
          console.debug(`Error response: ${JSON.stringify(resp)}`);
          done({ serverSaysError: resp });
        });
      }, newAdr);
      console.debug(`Result: ${j2s(result)}`);
      return result;
    },

    /* Currently not needed, maybe later?:

    tryChangeMyEmail: async (newAdr: St): Pr<{ emailAddresses } | { error }> => {
      const resp: A = await this.executeAsync(function(newAdr, done) {
        const store = window['theStore'];
        const me = store.me;
        // Calls: /-/set-primary-email-address
        // handled by: UserController.setPrimaryEmailAddresses().
        window['debiki2'].Server.setPrimaryEmailAddresses(me.id, newAdr, function(resp) {
          console.debug(`Emails after: ${JSON.stringify(resp)}`);
          done(resp);
        });
      }, newAdr);
      console.debug(`Emails after: ${JSON.stringify(resp)}`);
      return resp;
    },

    tryChangeMyPrefs: async (newAdr: St): Pr<V> => {
      const meAfter = await this.executeAsync(function(newAdr, done) {
        const store = window['theStore'];
        const me = store.me;
        window['debiki2'].Server.saveAboutPatPrefs({
          userId: me.id,
          emailAddress: newAdr,
          username: me.username,
          emailPref: 5, // ReceiveAlways  (whatever)
        }, false, // isGroup
        function(patAfter) { // onOk
          done(patAfter);
        });
      }, newAdr);
    }, */

    suspendUser: async (userId: PatId): Pr<ServerSays> => {
      const result: ServerSays = await this.executeAsync(function(userId, done) {
        // Calls:  /-/suspend-user
        // handled by:  UserController.suspendUser().
        window['debiki2'].Server.suspendUser(userId, 123, "No reason",
              function onOk(resp) {
                console.debug(`OK response: ${JSON.stringify(resp)}`);
                done({ serverSaysOk: resp });
              },
              function onErr(resp) {
                console.debug(`Error response: ${JSON.stringify(resp)}`);
                done({ serverSaysError: resp });
              });
      }, userId);
      console.debug(`Result: ${j2s(result)}`);
      return result;
    },
  };

    onlyAndSpec.indexOf('4br') >= 0 || settings.numBrowsers >= 4
        ? 4 : (
        ? 3 : (
    onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
        ? 2
        : 1));
  if (needsNumBrowsers >= 4) {
    config.capabilities.wdioBrowserD = {
      capabilities: { ...theCaps }
    };
  };

  // TESTS_MISSING: should not get any more live updates via WebSocket  TyTE0MOREWSMSG
  // Or is that better done in a separate e2e test file?

    // Deleting any session id cookie, logs us out.  Only cookie TyCoSid123
    // is not-HttpOnly and can be deleted, so let's delete it.
    // What! This is Webdriver.io, which can delete all cookies. But whatever,
    // let's delete 123 only, that's enough.
    owensBrowser.deleteCookie('TyCoSid123');  // TyTESESS123GONE
    Security: "Security",