feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)

- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
This commit is contained in:
null 2026-06-19 20:53:52 -05:00
parent e7b45cc84f
commit 3233c54ab2
28 changed files with 6738 additions and 140 deletions

1
.gitignore vendored
View File

@ -43,4 +43,5 @@ SecurityReport.md
# Firebase config (contains project ID, app ID, OAuth client, API key)
app/google-services.json
functions/node_modules/
firestore-tests/node_modules/
UI-PLAN.md

View File

@ -44,6 +44,7 @@ import app.closer.ui.pairing.EmailInviteScreen
import app.closer.ui.pairing.InviteConfirmScreen
import app.closer.ui.pairing.PairPromptScreen
import app.closer.ui.pairing.RecoveryScreen
import app.closer.ui.pairing.EncryptionUpgradeScreen
import app.closer.ui.dates.DateMatchScreen
import app.closer.ui.dates.DateMatchesScreen
import app.closer.ui.dates.DateBuilderScreen
@ -286,6 +287,20 @@ fun AppNavigation(
}
)
}
composable(route = AppRoute.ENCRYPTION_UPGRADE) {
EncryptionUpgradeScreen(
onComplete = {
navController.navigate(AppRoute.HOME) {
popUpTo(AppRoute.ENCRYPTION_UPGRADE) { inclusive = true }
}
},
onRecoveryNeeded = {
navController.navigate(AppRoute.RECOVERY) {
popUpTo(AppRoute.ENCRYPTION_UPGRADE) { inclusive = true }
}
}
)
}
// Wheel / Category Selection
composable(route = AppRoute.CATEGORY_PICKER) {

View File

@ -50,6 +50,7 @@ object AppRoute {
const val MEMORY_LANE = "memory_lane"
const val WAITING_FOR_PARTNER = "waiting_for_partner"
const val RECOVERY = "recovery"
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =
@ -108,7 +109,9 @@ object AppRoute {
Definition(DESIRE_SYNC, "Desire Sync", "play"),
Definition(CONNECTION_CHALLENGES, "Connection Challenges", "play"),
Definition(MEMORY_LANE, "Memory Lane", "play"),
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play")
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
Definition(RECOVERY, "Unlock Answers", "security"),
Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security")
)
val topLevelRoutes = setOf(

View File

@ -15,10 +15,15 @@ enum class EncryptionStatus {
RECONCILED_FROM_INVITE,
/** encryptionVersion == 1 but no local keyset — prompt for recovery phrase. */
NEEDS_RECOVERY,
/** encryptionVersion == 0 (old couple) — operates in plaintext passthrough. */
PLAINTEXT_COUPLE
/** encryptionVersion == 0 — this couple must create a key before writing more answers. */
NEEDS_ENCRYPTION_UPGRADE,
/** encryptionVersion == 1 with a local key — this device must rewrite its legacy answers. */
NEEDS_CONTENT_MIGRATION
}
class MissingCoupleKeyException(coupleId: String) :
IllegalStateException("Encrypted couple key is unavailable for $coupleId")
data class SetupResult(
val handle: KeysetHandle,
val wrapped: RecoveryKeyManager.WrappedKey,
@ -69,16 +74,30 @@ class CoupleEncryptionManager @Inject constructor(
* Handles inviter reconciliation (flow B) transparently.
*/
fun checkStatus(couple: Couple): EncryptionStatus {
if (couple.encryptionVersion == 0) return EncryptionStatus.PLAINTEXT_COUPLE
if (keyStore.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED
if (couple.encryptionVersion == 0) return EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE
if (keyStore.hasKeyset(couple.id)) {
return if (couple.encryptionVersion >= STRICT_ENCRYPTION_VERSION) {
EncryptionStatus.UNLOCKED
} else {
EncryptionStatus.NEEDS_CONTENT_MIGRATION
}
}
if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) {
return EncryptionStatus.RECONCILED_FROM_INVITE
return if (couple.encryptionVersion >= STRICT_ENCRYPTION_VERSION) {
EncryptionStatus.RECONCILED_FROM_INVITE
} else {
EncryptionStatus.NEEDS_CONTENT_MIGRATION
}
}
return EncryptionStatus.NEEDS_RECOVERY
}
fun aeadFor(coupleId: String): Aead? = keyStore.aeadFor(coupleId)
/** Answer-bearing writes must never fall back to plaintext. */
fun requireAead(coupleId: String): Aead =
keyStore.aeadFor(coupleId) ?: throw MissingCoupleKeyException(coupleId)
fun isUnlocked(coupleId: String): Boolean = keyStore.hasKeyset(coupleId)
/**
@ -98,4 +117,23 @@ class CoupleEncryptionManager @Inject constructor(
}
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId)
suspend fun setupLegacyCouple(coupleId: String): SetupResult = withContext(Dispatchers.Default) {
val phrase = keyManager.generateRecoveryPhrase()
val handle = keyManager.newCoupleKeyset()
val wrapped = keyManager.wrap(handle, phrase)
keyStore.storeKeyset(coupleId, handle)
keyStore.storePendingRecoveryPhrase(coupleId, phrase)
SetupResult(handle, wrapped, phrase)
}
fun pendingRecoveryPhrase(coupleId: String): String? =
keyStore.pendingRecoveryPhrase(coupleId)
fun acknowledgeRecoveryPhrase(coupleId: String) =
keyStore.clearPendingRecoveryPhrase(coupleId)
companion object {
const val STRICT_ENCRYPTION_VERSION = 2
}
}

View File

@ -69,10 +69,24 @@ class CoupleKeyStore @Inject constructor(
}
fun deleteKeyset(coupleId: String) {
prefs.edit().remove(prefKey(coupleId)).apply()
prefs.edit()
.remove(prefKey(coupleId))
.remove(pendingPhraseKey(coupleId))
.apply()
aeadCache.remove(coupleId)
}
fun storePendingRecoveryPhrase(coupleId: String, phrase: String) {
prefs.edit().putString(pendingPhraseKey(coupleId), phrase).apply()
}
fun pendingRecoveryPhrase(coupleId: String): String? =
prefs.getString(pendingPhraseKey(coupleId), null)
fun clearPendingRecoveryPhrase(coupleId: String) {
prefs.edit().remove(pendingPhraseKey(coupleId)).apply()
}
fun aeadFor(coupleId: String): Aead? {
aeadCache[coupleId]?.let { return it }
val handle = loadKeyset(coupleId) ?: return null
@ -83,6 +97,7 @@ class CoupleKeyStore @Inject constructor(
private fun prefKey(coupleId: String) = "keyset_$coupleId"
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
private fun serialize(handle: KeysetHandle): String {
val baos = ByteArrayOutputStream()

View File

@ -1,7 +1,7 @@
package app.closer.crypto
import android.util.Base64
import com.google.crypto.tink.Aead
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
@ -22,7 +22,7 @@ class FieldEncryptor @Inject constructor() {
plaintext.toByteArray(Charsets.UTF_8),
coupleId.toByteArray(Charsets.UTF_8)
)
return PREFIX + Base64.encodeToString(cipher, Base64.NO_WRAP)
return PREFIX + Base64.getEncoder().encodeToString(cipher)
}
fun encryptNullable(value: String?, aead: Aead, coupleId: String): String? =
@ -37,7 +37,7 @@ class FieldEncryptor @Inject constructor() {
if (!value.startsWith(PREFIX)) return value
if (aead == null) return null
return runCatching {
val cipher = Base64.decode(value.removePrefix(PREFIX), Base64.NO_WRAP)
val cipher = Base64.getDecoder().decode(value.removePrefix(PREFIX))
aead.decrypt(cipher, coupleId.toByteArray(Charsets.UTF_8))
.toString(Charsets.UTF_8)
}.getOrNull()

View File

@ -0,0 +1,180 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import com.google.crypto.tink.Aead
import com.google.firebase.firestore.DocumentReference
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldPath
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
/** One-time, per-user rewrite of every historical answer-bearing field to ciphertext. */
@Singleton
class CoupleAnswerMigrationDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
suspend fun migrateUser(coupleId: String, userId: String) {
val aead = encryptionManager.requireAead(coupleId)
migrateDailyAnswers(coupleId, userId, aead)
migrateThreadContent(coupleId, userId, aead)
migrateThisOrThat(coupleId, userId, aead)
migrateDesireSync(coupleId, userId, aead)
migrateHowWell(coupleId, userId, aead)
migrateWheel(coupleId, userId, aead)
}
private fun coupleRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
private suspend fun migrateDailyAnswers(coupleId: String, userId: String, aead: Aead) {
val days = coupleRef(coupleId)
.collection(FirestoreCollections.Couples.DAILY_QUESTION)
.get().await()
for (day in days.documents) {
val ref = day.reference
.collection(FirestoreCollections.DailyQuestion.ANSWERS)
.document(userId)
migrateAnswerDocument(ref, coupleId, aead)
}
}
private suspend fun migrateThreadContent(coupleId: String, userId: String, aead: Aead) {
val threads = coupleRef(coupleId)
.collection(FirestoreCollections.Couples.QUESTION_THREADS)
.get().await()
for (thread in threads.documents) {
migrateAnswerDocument(
thread.reference.collection(FirestoreCollections.QuestionThreads.ANSWERS).document(userId),
coupleId,
aead
)
val messages = thread.reference
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
.whereEqualTo("authorUserId", userId)
.get().await()
for (message in messages.documents) {
val text = message.getString("text") ?: continue
if (!fieldEncryptor.isEncrypted(text)) {
message.reference.update(
"text",
fieldEncryptor.encrypt(text, aead, coupleId)
).await()
}
}
}
}
private suspend fun migrateAnswerDocument(ref: DocumentReference, coupleId: String, aead: Aead) {
val snapshot = ref.get().await()
if (!snapshot.exists()) return
val updates = encryptedAnswerUpdates(snapshot, coupleId, aead)
if (updates.isNotEmpty()) ref.update(updates).await()
}
private fun encryptedAnswerUpdates(
snapshot: DocumentSnapshot,
coupleId: String,
aead: Aead
): Map<String, Any> {
val updates = mutableMapOf<String, Any>()
snapshot.getString("writtenText")?.let { value ->
if (!fieldEncryptor.isEncrypted(value)) {
updates["writtenText"] = fieldEncryptor.encrypt(value, aead, coupleId)
}
}
val rawIds = (snapshot.get("selectedOptionIds") as? List<*>)?.filterIsInstance<String>()
if (!rawIds.isNullOrEmpty() && !(rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0]))) {
updates["selectedOptionIds"] = listOf(
fieldEncryptor.encrypt(JSONArray(rawIds).toString(), aead, coupleId)
)
}
snapshot.get("scaleValue")?.let { value ->
if (value !is String || !fieldEncryptor.isEncrypted(value)) {
updates["scaleValue"] = fieldEncryptor.encrypt(value.toString(), aead, coupleId)
}
}
return updates
}
private suspend fun migrateThisOrThat(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.THIS_OR_THAT
) { value ->
val answers = (value as? List<*>)?.filterIsInstance<String>() ?: return@migrateUserAnswerCollection null
fieldEncryptor.encrypt(JSONArray(answers).toString(), aead, coupleId)
}
}
private suspend fun migrateDesireSync(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.DESIRE_SYNC
) { value ->
val list = (value as? List<*>)?.filterIsInstance<String>() ?: return@migrateUserAnswerCollection null
if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) list[0]
else fieldEncryptor.encrypt(JSONArray(list).toString(), aead, coupleId)
}
}
private suspend fun migrateHowWell(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.HOW_WELL
) { value ->
val list = value as? List<*> ?: return@migrateUserAnswerCollection null
if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) {
list[0] as String
} else {
fieldEncryptor.encrypt(JSONArray(list).toString(), aead, coupleId)
}
}
}
private suspend fun migrateWheel(coupleId: String, userId: String, aead: Aead) {
migrateUserAnswerCollection(
coupleId,
userId,
FirestoreCollections.Couples.WHEEL
) { value ->
val list = value as? List<*> ?: return@migrateUserAnswerCollection null
val normalized = list.mapNotNull { item ->
val map = item as? Map<*, *> ?: return@mapNotNull null
val rawDisplay = map["display"] as? String ?: ""
val display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
JSONObject().apply {
put("questionId", map["questionId"] as? String ?: "")
put("display", display)
}
}
fieldEncryptor.encrypt(JSONArray(normalized).toString(), aead, coupleId)
}
}
private suspend fun migrateUserAnswerCollection(
coupleId: String,
userId: String,
collection: String,
encryptLegacyValue: (Any?) -> String?
) {
val documents = coupleRef(coupleId).collection(collection).get().await()
for (document in documents.documents) {
val value = (document.get("answers") as? Map<*, *>)?.get(userId) ?: continue
if (value is String && fieldEncryptor.isEncrypted(value)) continue
val encrypted = encryptLegacyValue(value) ?: continue
document.reference.update(FieldPath.of("answers", userId), encrypted).await()
}
}
}

View File

@ -50,16 +50,16 @@ class FirestoreAnswerDataSource @Inject constructor(
answer: LocalAnswer
): Unit = suspendCancellableCoroutine { cont ->
val date = todayUtcString()
val aead = encryptionManager.aeadFor(coupleId)
val aead = encryptionManager.requireAead(coupleId)
val data = mapOf(
"userId" to userId,
"questionId" to questionId,
"answerType" to answer.answerType,
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
"writtenText" to fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId),
"selectedOptionIds" to if (answer.selectedOptionIds.isNotEmpty())
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
else answer.selectedOptionIds,
"scaleValue" to if (aead != null && answer.scaleValue != null)
"scaleValue" to if (answer.scaleValue != null)
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
else answer.scaleValue,
"createdAt" to answer.createdAt,

View File

@ -1,6 +1,7 @@
package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager
import app.closer.crypto.CoupleEncryptionManager
import app.closer.domain.model.Couple
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
@ -27,7 +28,7 @@ class FirestoreCoupleDataSource @Inject constructor(
inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
wrappedKey: RecoveryKeyManager.WrappedKey?
wrappedKey: RecoveryKeyManager.WrappedKey
): String {
val now = System.currentTimeMillis()
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now, wrappedKey)
@ -42,7 +43,7 @@ class FirestoreCoupleDataSource @Inject constructor(
acceptorUserId: String,
inviteCode: String,
now: Long,
wrappedKey: RecoveryKeyManager.WrappedKey?
wrappedKey: RecoveryKeyManager.WrappedKey
): Unit = suspendCancellableCoroutine { cont ->
val data = mutableMapOf<String, Any>(
"id" to coupleId,
@ -51,12 +52,10 @@ class FirestoreCoupleDataSource @Inject constructor(
"createdAt" to now,
"streakCount" to 0
)
if (wrappedKey != null) {
data["encryptionVersion"] = 1
data["wrappedCoupleKey"] = wrappedKey.cipherB64
data["kdfSalt"] = wrappedKey.saltB64
data["kdfParams"] = wrappedKey.params
}
data["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION
data["wrappedCoupleKey"] = wrappedKey.cipherB64
data["kdfSalt"] = wrappedKey.saltB64
data["kdfParams"] = wrappedKey.params
coupleRef(coupleId).set(data)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
@ -76,6 +75,47 @@ class FirestoreCoupleDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) }
}
/** Atomically claims a version-0 couple for client-side ciphertext migration. */
suspend fun beginEncryptionMigration(
coupleId: String,
wrappedKey: RecoveryKeyManager.WrappedKey
): Boolean = db.runTransaction { tx ->
val ref = coupleRef(coupleId)
val snapshot = tx.get(ref)
val version = (snapshot.getLong("encryptionVersion") ?: 0L).toInt()
if (version != 0) return@runTransaction false
tx.update(
ref,
mapOf(
"encryptionVersion" to 1,
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params,
"encryptionMigrationUsers" to emptyMap<String, Boolean>()
)
)
true
}.await()
/** Marks one partner's historical content migrated; version 2 requires both partners. */
suspend fun markEncryptionMigrationComplete(coupleId: String, userId: String): Boolean =
db.runTransaction { tx ->
val ref = coupleRef(coupleId)
val snapshot = tx.get(ref)
val userIds = (snapshot.get("userIds") as? List<*>)?.filterIsInstance<String>().orEmpty()
@Suppress("UNCHECKED_CAST")
val existing = snapshot.get("encryptionMigrationUsers") as? Map<String, Boolean>
?: emptyMap()
val completed = existing + (userId to true)
val allComplete = userIds.isNotEmpty() && userIds.all { completed[it] == true }
val updates = mutableMapOf<String, Any>("encryptionMigrationUsers" to completed)
if (allComplete) {
updates["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION
}
tx.update(ref, updates)
allComplete
}.await()
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
@ -135,7 +175,9 @@ class FirestoreCoupleDataSource @Inject constructor(
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
wrappedCoupleKey = getString("wrappedCoupleKey"),
kdfSalt = getString("kdfSalt"),
kdfParams = getString("kdfParams")
kdfParams = getString("kdfParams"),
encryptionMigrationUsers = (get("encryptionMigrationUsers") as? Map<String, Boolean>)
?: emptyMap()
)
companion object {

View File

@ -43,10 +43,8 @@ class FirestoreDesireSyncDataSource @Inject constructor(
userId: String,
optionIds: List<String>
) {
val aead = encryptionManager.aeadFor(coupleId)
val value = if (aead != null)
listOf(fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId))
else optionIds
val aead = encryptionManager.requireAead(coupleId)
val value = fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId)
doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
.await()
@ -79,8 +77,12 @@ class FirestoreDesireSyncDataSource @Inject constructor(
@Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) ->
val list = (value as? List<*>)?.filterIsInstance<String>() ?: return@mapNotNull null
// Encrypted as a single blob; plaintext as a real list
val list = when (value) {
is String -> listOf(value)
is List<*> -> value.filterIsInstance<String>()
else -> return@mapNotNull null
}
// Current format is an encrypted string; the single-item list supports v1 data.
val decrypted = if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) {
val json = fieldEncryptor.decrypt(list[0], aead, coupleId) ?: return@mapNotNull null
runCatching {

View File

@ -48,18 +48,14 @@ class FirestoreHowWellDataSource @Inject constructor(
userId: String,
answers: List<HowWellRawAnswer>
) {
val aead = encryptionManager.aeadFor(coupleId)
val value: Any = if (aead != null) {
val json = JSONArray(answers.map {
JSONObject().apply {
put("optionId", it.optionId ?: JSONObject.NULL)
put("scale", it.scale ?: JSONObject.NULL)
}
}.toString())
listOf(fieldEncryptor.encrypt(json.toString(), aead, coupleId))
} else {
answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) }
}
val aead = encryptionManager.requireAead(coupleId)
val json = JSONArray(answers.map {
JSONObject().apply {
put("optionId", it.optionId ?: JSONObject.NULL)
put("scale", it.scale ?: JSONObject.NULL)
}
}.toString())
val value = fieldEncryptor.encrypt(json.toString(), aead, coupleId)
doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
.await()
@ -91,7 +87,11 @@ class FirestoreHowWellDataSource @Inject constructor(
@Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) ->
val list = (value as? List<*>) ?: return@mapNotNull null
val list = when (value) {
is String -> listOf(value)
is List<*> -> value
else -> return@mapNotNull null
}
// Encrypted: single-element list with JSON blob
if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) {
val json = fieldEncryptor.decrypt(list[0] as String, aead, coupleId) ?: return@mapNotNull null

View File

@ -4,6 +4,7 @@ import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Invite
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import com.google.firebase.Timestamp
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
@ -30,8 +31,8 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
"code" to code,
"inviterUserId" to inviterUserId,
"status" to "pending",
"createdAt" to now,
"expiresAt" to now + 24 * 60 * 60 * 1000L,
"createdAt" to Timestamp.now(),
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params
@ -54,9 +55,12 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
inviteeEmail = snap.getString("inviteeEmail"),
coupleId = snap.getString("coupleId"),
status = snap.getString("status") ?: "pending",
createdAt = snap.getLong("createdAt") ?: 0L,
expiresAt = snap.getLong("expiresAt") ?: 0L,
acceptedAt = snap.getLong("acceptedAt"),
createdAt = snap.getTimestamp("createdAt")?.toDate()?.time
?: snap.getLong("createdAt") ?: 0L,
expiresAt = snap.getTimestamp("expiresAt")?.toDate()?.time
?: snap.getLong("expiresAt") ?: 0L,
acceptedAt = snap.getTimestamp("acceptedAt")?.toDate()?.time
?: snap.getLong("acceptedAt"),
acceptedByUserId = snap.getString("acceptedByUserId"),
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
kdfSalt = snap.getString("kdfSalt"),
@ -73,7 +77,7 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
mapOf(
"status" to "accepted",
"acceptedByUserId" to acceptorUserId,
"acceptedAt" to System.currentTimeMillis(),
"acceptedAt" to Timestamp.now(),
"coupleId" to coupleId
),
SetOptions.merge()

View File

@ -78,7 +78,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
val now = FieldValue.serverTimestamp()
val aead = encryptionManager.aeadFor(coupleId)
val aead = encryptionManager.requireAead(coupleId)
threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
@ -88,11 +88,11 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
"userId" to answer.userId,
"questionId" to answer.questionId,
"answerType" to answer.answerType,
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
"writtenText" to fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId),
"selectedOptionIds" to if (answer.selectedOptionIds.isNotEmpty())
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
else answer.selectedOptionIds,
"scaleValue" to if (aead != null && answer.scaleValue != null)
"scaleValue" to if (answer.scaleValue != null)
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
else answer.scaleValue,
"createdAt" to now,
@ -124,14 +124,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
// ─── Messages ────────────────────────────────────────────────────────────────
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
val aead = encryptionManager.aeadFor(coupleId)
val aead = encryptionManager.requireAead(coupleId)
threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
.add(
mapOf(
"authorUserId" to message.userId,
"text" to if (aead != null) fieldEncryptor.encrypt(message.text, aead, coupleId) else message.text,
"text" to fieldEncryptor.encrypt(message.text, aead, coupleId),
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()

View File

@ -1,11 +1,14 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import javax.inject.Inject
import javax.inject.Singleton
@ -27,7 +30,9 @@ data class ThisOrThatAnswers(
*/
@Singleton
class FirestoreThisOrThatDataSource @Inject constructor(
private val db: FirebaseFirestore
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES)
@ -42,8 +47,10 @@ class FirestoreThisOrThatDataSource @Inject constructor(
userId: String,
optionIds: List<String>
) {
val aead = encryptionManager.requireAead(coupleId)
val encrypted = fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId)
doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to optionIds)), SetOptions.merge())
.set(mapOf("answers" to mapOf(userId to encrypted)), SetOptions.merge())
.await()
}
@ -54,9 +61,7 @@ class FirestoreThisOrThatDataSource @Inject constructor(
if (!snap.exists()) return@runCatching null
@Suppress("UNCHECKED_CAST")
val raw = snap.get("answers") as? Map<String, *>
val byUser = raw.orEmpty().mapNotNull { (uid, value) ->
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
}.toMap()
val byUser = parseAnswers(raw, coupleId)
ThisOrThatAnswers(byUser)
}.getOrNull()
@ -67,11 +72,28 @@ class FirestoreThisOrThatDataSource @Inject constructor(
if (err != null || snap == null) return@addSnapshotListener
@Suppress("UNCHECKED_CAST")
val raw = snap.get("answers") as? Map<String, *>
val byUser = raw.orEmpty().mapNotNull { (uid, value) ->
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
}.toMap()
val byUser = parseAnswers(raw, coupleId)
trySend(ThisOrThatAnswers(byUser))
}
awaitClose { reg.remove() }
}
private fun parseAnswers(raw: Map<String, *>?, coupleId: String): Map<String, List<String>> {
val aead = encryptionManager.aeadFor(coupleId)
return raw.orEmpty().mapNotNull { (uid, value) ->
when (value) {
is String -> {
val json = fieldEncryptor.decrypt(value, aead, coupleId) ?: return@mapNotNull null
val answers = runCatching {
val array = JSONArray(json)
(0 until array.length()).map { array.getString(it) }
}.getOrNull() ?: return@mapNotNull null
uid to answers
}
// Version-0 compatibility exists only until this user completes migration.
is List<*> -> uid to value.filterIsInstance<String>()
else -> null
}
}.toMap()
}
}

View File

@ -9,6 +9,8 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
@ -53,18 +55,17 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
questions: List<WheelQuestionRef>,
answers: List<WheelAnswerEntry>
) {
val aead = encryptionManager.aeadFor(coupleId)
val aead = encryptionManager.requireAead(coupleId)
val answerJson = JSONArray(answers.map {
JSONObject().apply {
put("questionId", it.questionId)
put("display", it.display)
}
}.toString())
val data = mapOf(
"categoryName" to categoryName,
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
"answers" to mapOf(
userId to answers.map {
mapOf(
"questionId" to it.questionId,
"display" to if (aead != null) fieldEncryptor.encrypt(it.display, aead, coupleId) else it.display
)
}
)
"answers" to mapOf(userId to fieldEncryptor.encrypt(answerJson.toString(), aead, coupleId))
)
doc(coupleId, sessionId).set(data, SetOptions.merge()).await()
}
@ -95,14 +96,31 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
@Suppress("UNCHECKED_CAST")
val rawAnswers = snap.get("answers") as? Map<String, *>
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
(value as? List<*>).orEmpty().mapNotNull { item ->
(item as? Map<*, *>)?.let {
val rawDisplay = it["display"] as? String ?: ""
WheelAnswerEntry(
questionId = it["questionId"] as? String ?: "",
display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
)
when (value) {
is String -> {
val json = fieldEncryptor.decrypt(value, aead, coupleId) ?: return@mapValues emptyList()
runCatching {
val array = JSONArray(json)
(0 until array.length()).map { index ->
val item = array.getJSONObject(index)
WheelAnswerEntry(
questionId = item.optString("questionId"),
display = item.optString("display")
)
}
}.getOrDefault(emptyList())
}
// Version-0/1 compatibility exists only until migration completes.
is List<*> -> value.mapNotNull { item ->
(item as? Map<*, *>)?.let {
val rawDisplay = it["display"] as? String ?: ""
WheelAnswerEntry(
questionId = it["questionId"] as? String ?: "",
display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
)
}
}
else -> emptyList()
}
}
return WheelRevealDoc(

View File

@ -46,12 +46,10 @@ class CoupleRepositoryImpl @Inject constructor(
saltB64 = invite.kdfSalt ?: error("Missing kdfSalt on invite"),
params = invite.kdfParams ?: error("Missing kdfParams on invite")
)
} else null
} else error("Invite is missing its encrypted couple key")
if (wrappedKey != null) {
encryptionManager.unwrapAndStore(coupleId, wrappedKey, recoveryPhrase)
.getOrElse { throw it }
}
encryptionManager.unwrapAndStore(coupleId, wrappedKey, recoveryPhrase)
.getOrElse { throw it }
coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey)
}

View File

@ -9,9 +9,10 @@ data class Couple(
val streakCount: Int = 0,
val lastAnsweredAt: Long? = null,
val activePackId: String? = null,
// E2EE: version 0 = plaintext, version 1 = Tink AES256-GCM + Argon2id recovery
// E2EE: 0 = legacy plaintext, 1 = migration in progress, 2 = all answer paths strict E2EE.
val encryptionVersion: Int = 0,
val wrappedCoupleKey: String? = null,
val kdfSalt: String? = null,
val kdfParams: String? = null
val kdfParams: String? = null,
val encryptionMigrationUsers: Map<String, Boolean> = emptyMap()
)

View File

@ -83,6 +83,12 @@ fun HomeScreen(
}
}
LaunchedEffect(state.needsEncryptionUpgrade) {
if (state.needsEncryptionUpgrade) {
onNavigate(AppRoute.ENCRYPTION_UPGRADE)
}
}
HomeContent(
state = state,
snackbarHostState = snackbarHostState,

View File

@ -76,7 +76,8 @@ data class HomeUiState(
val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false
val needsRecovery: Boolean = false,
val needsEncryptionUpgrade: Boolean = false
)
@HiltViewModel
@ -127,8 +128,14 @@ class HomeViewModel @Inject constructor(
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
.getOrNull()
}
val needsRecovery = couple != null &&
encryptionManager.checkStatus(couple) == EncryptionStatus.NEEDS_RECOVERY
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
val needsEncryptionUpgrade = when (encryptionStatus) {
EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE -> true
EncryptionStatus.NEEDS_CONTENT_MIGRATION ->
couple.encryptionMigrationUsers[uid] != true
else -> false
}
_uiState.update {
it.copy(
isLoading = false,
@ -138,7 +145,8 @@ class HomeViewModel @Inject constructor(
streakCount = couple?.streakCount ?: 0,
isPaired = couple != null,
partnerLeftEvent = false,
needsRecovery = needsRecovery
needsRecovery = needsRecovery,
needsEncryptionUpgrade = needsEncryptionUpgrade
).withHomeActions()
}
} catch (e: Exception) {

View File

@ -0,0 +1,246 @@
package app.closer.ui.pairing
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.data.remote.CoupleAnswerMigrationDataSource
import app.closer.data.remote.FirestoreCoupleDataSource
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.StatusGlyph
import app.closer.ui.settings.SettingsBackgroundBrush
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsOnPrimary
import app.closer.ui.settings.SettingsPrimary
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class EncryptionUpgradeUiState(
val isLoading: Boolean = true,
val recoveryPhrase: String? = null,
val complete: Boolean = false,
val allPartnersComplete: Boolean = false,
val error: String? = null
)
@HiltViewModel
class EncryptionUpgradeViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val coupleDataSource: FirestoreCoupleDataSource,
private val migrationDataSource: CoupleAnswerMigrationDataSource,
private val encryptionManager: CoupleEncryptionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(EncryptionUpgradeUiState())
val uiState: StateFlow<EncryptionUpgradeUiState> = _uiState.asStateFlow()
private var coupleId: String? = null
init {
upgrade()
}
fun upgrade() {
if (_uiState.value.isLoading && coupleId != null) return
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
runCatching {
val userId = authRepository.currentUserId ?: error("Sign in again to secure your history.")
var couple = coupleRepository.getCoupleForUser(userId)
?: error("Your couple could not be loaded.")
coupleId = couple.id
if (couple.encryptionVersion >= CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION) {
return@runCatching UpgradeResult(null, true)
}
var phrase = encryptionManager.pendingRecoveryPhrase(couple.id)
if (couple.encryptionVersion == 0) {
val wrapped = if (phrase != null && encryptionManager.isUnlocked(couple.id)) {
encryptionManager.rewrapWithNewPhrase(couple.id, phrase).getOrThrow()
} else {
val setup = encryptionManager.setupLegacyCouple(couple.id)
phrase = setup.recoveryPhrase
setup.wrapped
}
val claimed = coupleDataSource.beginEncryptionMigration(couple.id, wrapped)
if (!claimed) {
encryptionManager.deleteKeyset(couple.id)
error("Your partner started the upgrade. Ask them for the recovery phrase, then unlock this device.")
}
couple = coupleRepository.getCoupleForUser(userId)
?: error("Your couple could not be reloaded.")
}
if (!encryptionManager.isUnlocked(couple.id)) {
error("This device needs your shared recovery phrase before it can migrate answers.")
}
migrationDataSource.migrateUser(couple.id, userId)
val allComplete = coupleDataSource.markEncryptionMigrationComplete(couple.id, userId)
UpgradeResult(phrase, allComplete)
}.onSuccess { result ->
_uiState.update {
it.copy(
isLoading = false,
recoveryPhrase = result.phrase,
complete = true,
allPartnersComplete = result.allPartnersComplete
)
}
}.onFailure { error ->
_uiState.update {
it.copy(isLoading = false, error = error.message ?: "The encryption upgrade failed. Try again.")
}
}
}
}
fun acknowledgePhrase() {
coupleId?.let(encryptionManager::acknowledgeRecoveryPhrase)
}
private data class UpgradeResult(val phrase: String?, val allPartnersComplete: Boolean)
}
@Composable
fun EncryptionUpgradeScreen(
onComplete: () -> Unit,
onRecoveryNeeded: () -> Unit,
viewModel: EncryptionUpgradeViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(SettingsBackgroundBrush)
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
StatusGlyph(
icon = Icons.Filled.Lock,
tint = SettingsPrimary,
container = SettingsPrimary.copy(alpha = 0.14f)
)
Spacer(Modifier.height(20.dp))
Text(
text = if (state.complete) "Your answers are secured" else "Securing your history",
style = MaterialTheme.typography.headlineSmall,
color = SettingsInk,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
when {
state.isLoading -> {
Text(
"This one-time upgrade encrypts answer content on this device before replacing its cloud copy.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = SettingsPrimary)
Spacer(Modifier.height(16.dp))
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
}
state.error != null -> {
val error = state.error.orEmpty()
Text(
error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
Button(onClick = viewModel::upgrade) { Text("Try again") }
if (error.contains("recovery phrase", ignoreCase = true)) {
OutlinedButton(onClick = onRecoveryNeeded) { Text("Enter recovery phrase") }
}
}
state.recoveryPhrase != null -> {
val recoveryPhrase = state.recoveryPhrase.orEmpty()
Text(
"Save this phrase somewhere private and share it directly with your partner. Closer does not store the phrase and cannot recover it.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
SelectionContainer {
Text(
recoveryPhrase,
modifier = Modifier.fillMaxWidth().padding(16.dp),
style = MaterialTheme.typography.titleMedium,
color = SettingsInk,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { viewModel.acknowledgePhrase(); onComplete() },
modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp),
colors = ButtonDefaults.buttonColors(
containerColor = SettingsPrimary,
contentColor = SettingsOnPrimary
)
) { Text("I've saved and shared it") }
}
else -> {
Text(
if (state.allPartnersComplete)
"Both sides have migrated. New and historical answer content now uses strict end-to-end encryption."
else
"This device is ready. Your partner will finish the upgrade after unlocking with the shared recovery phrase.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(onClick = onComplete, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
}
}
}
}

View File

@ -0,0 +1,61 @@
package app.closer.crypto
import com.google.crypto.tink.Aead
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.security.GeneralSecurityException
import java.security.MessageDigest
class FieldEncryptorTest {
private val subject = FieldEncryptor()
private val aead = AssociatedDataCheckingAead()
@Test
fun `encrypted wire value never contains the plaintext`() {
val encrypted = subject.encrypt("our private answer", aead, "couple-a")
assertTrue(encrypted.startsWith(FieldEncryptor.PREFIX))
assertFalse(encrypted.contains("our private answer"))
assertTrue(subject.isEncrypted(encrypted))
}
@Test
fun `encrypted value round trips for the same couple`() {
val encrypted = subject.encrypt("yes, absolutely", aead, "couple-a")
assertEquals("yes, absolutely", subject.decrypt(encrypted, aead, "couple-a"))
}
@Test
fun `ciphertext cannot be moved to a different couple`() {
val encrypted = subject.encrypt("bound to us", aead, "couple-a")
assertNull(subject.decrypt(encrypted, aead, "couple-b"))
}
@Test
fun `encrypted value stays unreadable when the key is unavailable`() {
val encrypted = subject.encrypt("key required", aead, "couple-a")
assertNull(subject.decrypt(encrypted, null, "couple-a"))
}
private class AssociatedDataCheckingAead : Aead {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
digest(associatedData) + plaintext.reversedArray()
override fun decrypt(ciphertext: ByteArray, associatedData: ByteArray): ByteArray {
val expected = digest(associatedData)
if (ciphertext.size < expected.size || !ciphertext.copyOfRange(0, expected.size).contentEquals(expected)) {
throw GeneralSecurityException("Associated data does not match")
}
return ciphertext.copyOfRange(expected.size, ciphertext.size).reversedArray()
}
private fun digest(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value).copyOf(8)
}
}

View File

@ -6,6 +6,12 @@
"storage": {
"rules": "storage.rules"
},
"emulators": {
"firestore": {
"host": "127.0.0.1",
"port": 8180
}
},
"functions": [
{
"source": "functions",

867
firestore-debug.log Normal file
View File

@ -0,0 +1,867 @@
Jun 19, 2026 8:53:39 PM com.google.cloud.datastore.emulator.firestore.websocket.WebSocketServer start
INFO: Started WebSocket server on ws://127.0.0.1:9150
API endpoint: http://127.0.0.1:8180
Database Edition: STANDARD
Database Mode: CLOUD_FIRESTORE
If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run:
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8180
If you are running a Firestore in Datastore Mode project, run:
export DATASTORE_EMULATOR_HOST=127.0.0.1:8180
Note: Support for Datastore Mode is in preview. If you encounter any bugs please file at https://github.com/firebase/firebase-tools/issues.
Dev App Server is now running.
Jun 19, 2026 8:53:41 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Jun 19, 2026 8:53:41 PM io.grpc.netty.TcpMetrics loadEpollInfo
INFO: Epoll available during static init of TcpMetrics:false
Jun 19, 2026 8:53:41 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Jun 19, 2026 8:53:41 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected non-HTTP/2 connection.
Jun 19, 2026 8:53:42 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L130, evaluation error at L132:24 for 'update' @ L132, false for 'create' @ L130
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L130, evaluation error at L132:24 for 'update' @ L132, false for 'create' @ L130
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L132:24 for 'update' @ L132, false for 'update' @ L132
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L132:24 for 'update' @ L132, false for 'update' @ L132
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L139, false for 'update' @ L139
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L139, false for 'update' @ L139
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L145, false for 'update' @ L145
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L145, false for 'update' @ L145
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L160, false for 'update' @ L160
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L160, false for 'update' @ L160
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L186, false for 'update' @ L202
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L186, false for 'update' @ L202
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L186, false for 'update' @ L202
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L186, false for 'update' @ L202
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L202:24 for 'update' @ L202, false for 'update' @ L202
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L202:24 for 'update' @ L202, false for 'update' @ L202
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L239, evaluation error at L258:24 for 'update' @ L258, false for 'create' @ L239
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L239, evaluation error at L258:24 for 'update' @ L258, false for 'create' @ L239
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'delete' @ L268
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'delete' @ L268
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:42 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L258:24 for 'update' @ L258, false for 'update' @ L258
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L258:24 for 'update' @ L258, Property user_bob is undefined on object. for 'update' @ L258
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L258:24 for 'update' @ L258, Property user_bob is undefined on object. for 'update' @ L258
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: com.google.firebase.rules.runtime.common.EvaluationException: Error: /home/kaspa/.openclaw/Projects/relationship-app/firestore.rules line [105], column [47]. Property user_bob is undefined on object.
at com.google.firebase.rules.runtime.impl.DefaultEvaluator$TransformEvaluationException.apply(DefaultEvaluator.java:275)
at com.google.firebase.rules.runtime.impl.DefaultEvaluator$TransformEvaluationException.apply(DefaultEvaluator.java:261)
at com.google.common.util.concurrent.AbstractCatchingFuture$AsyncCatchingFuture.doFallback(AbstractCatchingFuture.java:207)
at com.google.common.util.concurrent.AbstractCatchingFuture$AsyncCatchingFuture.doFallback(AbstractCatchingFuture.java:194)
at com.google.common.util.concurrent.AbstractCatchingFuture.run(AbstractCatchingFuture.java:136)
at com.google.common.util.concurrent.DirectExecutor.execute(DirectExecutor.java:30)
at com.google.common.util.concurrent.AbstractFuture.executeListener(AbstractFuture.java:1024)
at com.google.common.util.concurrent.AbstractFuture.addListener(AbstractFuture.java:486)
at com.google.common.util.concurrent.FluentFuture$TrustedFuture.addListener(FluentFuture.java:122)
at com.google.common.util.concurrent.AbstractCatchingFuture.createAsync(AbstractCatchingFuture.java:58)
at com.google.common.util.concurrent.Futures.catchingAsync(Futures.java:409)
at com.google.firebase.rules.runtime.impl.DefaultEvaluator.evaluate(DefaultEvaluator.java:177)
at com.google.cloud.datastore.emulator.impl.rules.EmulatorRuleClient$EmulatorRuleEvaluator.evaluate(EmulatorRuleClient.java:88)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer.lambda$fullEvaluate$0(AsyncTwoPhaseRulesAuthorizer.java:1001)
at com.google.cloud.datastore.computation.Computation.tryCatch(Computation.java:469)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer.fullEvaluate(AsyncTwoPhaseRulesAuthorizer.java:1000)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer$CommitAuthorizerImpl.lambda$checkWrite$0(AsyncTwoPhaseRulesAuthorizer.java:1237)
at com.google.cloud.datastore.computation.Computation.lambda$tryClose$1(Computation.java:738)
at com.google.cloud.datastore.computation.Computation.tryCatch(Computation.java:469)
at com.google.cloud.datastore.computation.Computation.tryFinally(Computation.java:705)
at com.google.cloud.datastore.computation.Computation.tryFinally(Computation.java:711)
at com.google.cloud.datastore.computation.Computation.tryClose(Computation.java:738)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer$CommitAuthorizerImpl.checkWrite(AsyncTwoPhaseRulesAuthorizer.java:1206)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.lambda$checkCommit$2(DefaultEmulatorRulesAuthorizer.java:125)
at com.google.cloud.datastore.computation.Computation.run(Computation.java:81)
at com.google.cloud.datastore.computation.Computation$Terminated.toFuture(Computation.java:947)
at com.google.cloud.datastore.computation.Computation.toFuture(Computation.java:92)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.finish(DefaultEmulatorRulesAuthorizer.java:308)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.lambda$checkCommit$0(DefaultEmulatorRulesAuthorizer.java:123)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:321)
... 22 more
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'create' @ L275, false for 'create' @ L476, evaluation error at L280:26 for 'update' @ L280, false for 'update' @ L483, false for 'create' @ L275, false for 'create' @ L476
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'create' @ L275, false for 'create' @ L476, evaluation error at L280:26 for 'update' @ L280, false for 'update' @ L483, false for 'create' @ L275, false for 'create' @ L476
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'delete' @ L291, false for 'delete' @ L491
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'delete' @ L291, false for 'delete' @ L491
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L300:26 for 'create' @ L300, false for 'create' @ L476, evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'create' @ L300, false for 'create' @ L476
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L300:26 for 'create' @ L300, false for 'create' @ L476, evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'create' @ L300, false for 'create' @ L476
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L300:26 for 'create' @ L300, false for 'create' @ L476, evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, Property createdByUserId is undefined on object. for 'create' @ L300, false for 'create' @ L476
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L300:26 for 'create' @ L300, false for 'create' @ L476, evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, Property createdByUserId is undefined on object. for 'create' @ L300, false for 'create' @ L476
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: com.google.firebase.rules.runtime.common.EvaluationException: Error: /home/kaspa/.openclaw/Projects/relationship-app/firestore.rules line [301], column [14]. Property createdByUserId is undefined on object.
at com.google.firebase.rules.runtime.impl.DefaultEvaluator$TransformEvaluationException.apply(DefaultEvaluator.java:275)
at com.google.firebase.rules.runtime.impl.DefaultEvaluator$TransformEvaluationException.apply(DefaultEvaluator.java:261)
at com.google.common.util.concurrent.AbstractCatchingFuture$AsyncCatchingFuture.doFallback(AbstractCatchingFuture.java:207)
at com.google.common.util.concurrent.AbstractCatchingFuture$AsyncCatchingFuture.doFallback(AbstractCatchingFuture.java:194)
at com.google.common.util.concurrent.AbstractCatchingFuture.run(AbstractCatchingFuture.java:136)
at com.google.common.util.concurrent.DirectExecutor.execute(DirectExecutor.java:30)
at com.google.common.util.concurrent.AbstractFuture.executeListener(AbstractFuture.java:1024)
at com.google.common.util.concurrent.AbstractFuture.addListener(AbstractFuture.java:486)
at com.google.common.util.concurrent.FluentFuture$TrustedFuture.addListener(FluentFuture.java:122)
at com.google.common.util.concurrent.AbstractCatchingFuture.createAsync(AbstractCatchingFuture.java:58)
at com.google.common.util.concurrent.Futures.catchingAsync(Futures.java:409)
at com.google.firebase.rules.runtime.impl.DefaultEvaluator.evaluate(DefaultEvaluator.java:177)
at com.google.cloud.datastore.emulator.impl.rules.EmulatorRuleClient$EmulatorRuleEvaluator.evaluate(EmulatorRuleClient.java:88)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer.lambda$fullEvaluate$0(AsyncTwoPhaseRulesAuthorizer.java:1001)
at com.google.cloud.datastore.computation.Computation.tryCatch(Computation.java:469)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer.fullEvaluate(AsyncTwoPhaseRulesAuthorizer.java:1000)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer$CommitAuthorizerImpl.lambda$checkWrite$0(AsyncTwoPhaseRulesAuthorizer.java:1237)
at com.google.cloud.datastore.computation.Computation.lambda$tryClose$1(Computation.java:738)
at com.google.cloud.datastore.computation.Computation.tryCatch(Computation.java:469)
at com.google.cloud.datastore.computation.Computation.tryFinally(Computation.java:705)
at com.google.cloud.datastore.computation.Computation.tryFinally(Computation.java:711)
at com.google.cloud.datastore.computation.Computation.tryClose(Computation.java:738)
at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer$CommitAuthorizerImpl.checkWrite(AsyncTwoPhaseRulesAuthorizer.java:1206)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.lambda$checkCommit$2(DefaultEmulatorRulesAuthorizer.java:125)
at com.google.cloud.datastore.computation.Computation.run(Computation.java:81)
at com.google.cloud.datastore.computation.Computation$Terminated.toFuture(Computation.java:947)
at com.google.cloud.datastore.computation.Computation.toFuture(Computation.java:92)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.finish(DefaultEmulatorRulesAuthorizer.java:308)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.lambda$checkCommit$0(DefaultEmulatorRulesAuthorizer.java:123)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:321)
... 22 more
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
com.google.cloud.datastore.core.exception.DatastoreException:
evaluation error at L304:26 for 'update' @ L304, false for 'update' @ L483, false for 'update' @ L304, false for 'update' @ L483
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Jun 19, 2026 8:53:43 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError
WARNING: Operation failed:
false for 'delete' @ L317, false for 'delete' @ L491
com.google.cloud.datastore.core.exception.DatastoreException:
false for 'delete' @ L317, false for 'delete' @ L491
at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:152)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:349)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:326)
at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:386)
at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:336)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.internalCommit(CloudFirestoreV1.java:1245)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.write(CloudFirestoreV1.java:1232)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.handleRequest(CloudFirestoreV1WriteStream.java:222)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.write(CloudFirestoreV1WriteStream.java:144)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:99)
at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1WriteStream.onNext(CloudFirestoreV1WriteStream.java:27)
at io.grpc.stub.ServerCalls$StreamingServerCallHandler$StreamingServerCallListener.onMessage(ServerCalls.java:262)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.ForwardingServerCallListener.onMessage(ForwardingServerCallListener.java:33)
at io.grpc.Contexts$ContextualizedServerCallListener.onMessage(Contexts.java:76)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailableInternal(ServerCallImpl.java:334)
at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.messagesAvailable(ServerCallImpl.java:319)
at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1MessagesAvailable.runInContext(ServerImpl.java:834)
at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37)
at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)

View File

@ -1,6 +1,6 @@
// Runs once before the full test suite.
// The Firestore emulator must already be running on port 8080 before running tests.
// The Firestore emulator must already be running on port 8180 before running tests.
// Start it with: firebase emulators:start --only firestore
export default async function () {
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8180";
}

4786
firestore-tests/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -15,10 +15,9 @@ import {
assertFails,
assertSucceeds,
initializeTestEnvironment,
RulesTestContext,
RulesTestEnvironment,
} from "@firebase/rules-unit-testing";
import { readFileSync } from "fs";
import { join } from "path";
import {
doc,
setDoc,
@ -33,18 +32,25 @@ import {
// ── Test environment ──────────────────────────────────────────────────────────
const PROJECT_ID = "closer-rules-test";
const PROJECT_ID = process.env.GCLOUD_PROJECT ?? "couples-connect-dev";
let testEnv: RulesTestEnvironment;
let aliceContext: RulesTestContext;
let bobContext: RulesTestContext;
let charlieContext: RulesTestContext;
let anonContext: RulesTestContext;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: PROJECT_ID,
firestore: {
rules: readFileSync(join(__dirname, "../firestore.rules"), "utf8"),
host: "127.0.0.1",
port: 8080,
port: 8180,
},
});
aliceContext = testEnv.authenticatedContext(UID_A);
bobContext = testEnv.authenticatedContext(UID_B);
charlieContext = testEnv.authenticatedContext(UID_C);
anonContext = testEnv.unauthenticatedContext();
});
afterAll(async () => {
@ -62,12 +68,18 @@ const UID_B = "user_bob";
const UID_C = "user_charlie"; // outsider
const COUPLE_ID = "couple_ab";
const COUPLE_DOC = {
id: COUPLE_ID,
userIds: [UID_A, UID_B],
inviteCode: "ABC123",
createdAt: 1_000_000,
streakCount: 0,
lastAnsweredAt: null,
encryptionVersion: 2,
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
};
const CIPHERTEXT = "enc:v1:YWJj";
/** Seed documents that rules' helper functions need (e.g. isCouplesMember reads the couple). */
async function seedCouple() {
@ -85,10 +97,10 @@ async function seedUser(uid: string, coupleId?: string) {
});
}
const alice = () => testEnv.authenticatedContext(UID_A);
const bob = () => testEnv.authenticatedContext(UID_B);
const charlie = () => testEnv.authenticatedContext(UID_C);
const anon = () => testEnv.unauthenticatedContext();
const alice = () => aliceContext;
const bob = () => bobContext;
const charlie = () => charlieContext;
const anon = () => anonContext;
// ── users/{uid} ───────────────────────────────────────────────────────────────
@ -222,7 +234,11 @@ describe("invites/{code}", () => {
inviterUserId: UID_A,
code: INVITE_CODE,
status: "pending",
createdAt: Timestamp.now(),
expiresAt,
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
...extra,
});
});
@ -234,7 +250,11 @@ describe("invites/{code}", () => {
inviterUserId: UID_A,
code: INVITE_CODE,
status: "pending",
createdAt: Timestamp.now(),
expiresAt,
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
})
);
});
@ -245,7 +265,11 @@ describe("invites/{code}", () => {
inviterUserId: UID_A,
code: INVITE_CODE,
status: "pending",
createdAt: Timestamp.now(),
expiresAt,
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
coupleId: "injected",
})
);
@ -257,7 +281,11 @@ describe("invites/{code}", () => {
inviterUserId: UID_B,
code: INVITE_CODE,
status: "pending",
createdAt: Timestamp.now(),
expiresAt,
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
})
);
});
@ -285,7 +313,7 @@ describe("invites/{code}", () => {
await assertSucceeds(
updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), {
status: "accepted",
acceptorUserId: UID_B,
acceptedByUserId: UID_B,
acceptedAt: Timestamp.now(),
})
);
@ -297,7 +325,7 @@ describe("invites/{code}", () => {
await assertFails(
updateDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), {
status: "accepted",
acceptorUserId: UID_A,
acceptedByUserId: UID_A,
acceptedAt: Timestamp.now(),
})
);
@ -328,8 +356,8 @@ describe("couples/{coupleId}", () => {
);
});
test("member can update allowed fields — allowed", async () => {
await assertSucceeds(
test("member cannot inject a custom couple field — denied", async () => {
await assertFails(
updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
someCustomField: "hello",
})
@ -372,6 +400,90 @@ describe("couples/{coupleId}", () => {
test("couple cannot be deleted by client — denied", async () => {
await assertFails(deleteDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`)));
});
describe("encryption migration", () => {
const legacyCouple = {
id: COUPLE_ID,
userIds: [UID_A, UID_B],
inviteCode: "ABC123",
createdAt: 1_000_000,
streakCount: 0,
lastAnsweredAt: null,
encryptionVersion: 0,
};
test("a member can start encryption migration — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), legacyCouple);
});
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
encryptionVersion: 1,
encryptionMigrationUsers: {},
}));
});
test("a member can mark only their own migration complete — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const versionOne = { ...COUPLE_DOC, encryptionVersion: 1 };
delete (versionOne as Record<string, unknown>).encryptionMigrationUsers;
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), versionOne);
});
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 1,
encryptionMigrationUsers: { [UID_A]: true },
}));
});
test("a member cannot claim their partner migrated — denied", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
...COUPLE_DOC,
encryptionVersion: 1,
encryptionMigrationUsers: {},
});
});
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 1,
encryptionMigrationUsers: { [UID_B]: true },
}));
});
test("version 2 requires both partners to complete migration — denied", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
...COUPLE_DOC,
encryptionVersion: 1,
encryptionMigrationUsers: {},
});
});
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 2,
encryptionMigrationUsers: { [UID_A]: true },
}));
});
test("the second partner can complete migration and promote to version 2 — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
...COUPLE_DOC,
encryptionVersion: 1,
encryptionMigrationUsers: { [UID_A]: true },
});
});
await assertSucceeds(updateDoc(doc(bob().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 2,
encryptionMigrationUsers: { [UID_A]: true, [UID_B]: true },
}));
});
});
});
// ── couples/{coupleId}/sessions/{sessionId} ──────────────────────────────────
@ -593,6 +705,19 @@ describe("couples/{coupleId}/question_threads/{threadId}", () => {
test("owner can write own answer — allowed", async () => {
await assertSucceeds(
setDoc(doc(alice().firestore(), ANSWER_PATH), {
userId: UID_A,
questionId: "q1",
answerType: "written",
writtenText: CIPHERTEXT,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
})
);
});
test("owner cannot write a plaintext answer — denied", async () => {
await assertFails(
setDoc(doc(alice().firestore(), ANSWER_PATH), {
userId: UID_A,
questionId: "q1",
@ -641,7 +766,7 @@ describe("couples/{coupleId}/question_threads/{threadId}", () => {
await assertSucceeds(
addDoc(collection(alice().firestore(), MSGS_PATH), {
authorUserId: UID_A,
text: "Hi",
text: CIPHERTEXT,
createdAt: serverTimestamp(),
})
);
@ -678,36 +803,39 @@ describe("couples/{coupleId}/question_threads/{threadId}", () => {
});
test("member can read messages — allowed", async () => {
const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => {
return addDoc(collection(ctx.firestore(), MSGS_PATH), {
const msgPath = `${MSGS_PATH}/readable-message`;
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), msgPath), {
authorUserId: UID_A,
text: "Hi",
});
});
await assertSucceeds(getDoc(doc(bob().firestore(), msgRef.path)));
await assertSucceeds(getDoc(doc(bob().firestore(), msgPath)));
});
test("author can update own message — allowed", async () => {
const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => {
return addDoc(collection(ctx.firestore(), MSGS_PATH), {
const msgPath = `${MSGS_PATH}/owned-message`;
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), msgPath), {
authorUserId: UID_A,
text: "Hi",
});
});
await assertSucceeds(
updateDoc(doc(alice().firestore(), msgRef.path), { text: "Updated" })
updateDoc(doc(alice().firestore(), msgPath), { text: CIPHERTEXT })
);
});
test("other member cannot update someone else's message — denied", async () => {
const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => {
return addDoc(collection(ctx.firestore(), MSGS_PATH), {
const msgPath = `${MSGS_PATH}/other-message`;
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), msgPath), {
authorUserId: UID_A,
text: "Hi",
});
});
await assertFails(
updateDoc(doc(bob().firestore(), msgRef.path), { text: "Tampered" })
updateDoc(doc(bob().firestore(), msgPath), { text: "Tampered" })
);
});
});
@ -1024,6 +1152,53 @@ describe("couples/{coupleId}/daily_question/{date}", () => {
});
});
// ── Private game answers ────────────────────────────────────────────────────
describe.each(["this_or_that", "desire_sync", "how_well", "wheel"])(
"couples/{coupleId}/%s/{sessionId}",
(gameCollection) => {
const gamePath = () => `couples/${COUPLE_ID}/${gameCollection}/session1`;
beforeEach(seedCouple);
test("a member can submit an encrypted answer — allowed", async () => {
await assertSucceeds(setDoc(doc(alice().firestore(), gamePath()), {
answers: { [UID_A]: CIPHERTEXT },
}));
});
test("a plaintext answer is rejected — denied", async () => {
await assertFails(setDoc(doc(alice().firestore(), gamePath()), {
answers: { [UID_A]: "private answer" },
}));
});
test("a partner cannot overwrite the other user's answer — denied", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), gamePath()), {
answers: { [UID_A]: CIPHERTEXT },
});
});
await assertFails(updateDoc(doc(bob().firestore(), gamePath()), {
[`answers.${UID_A}`]: "enc:v1:ZGVm",
}));
});
test("a partner can add their own encrypted answer — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), gamePath()), {
answers: { [UID_A]: CIPHERTEXT },
});
});
await assertSucceeds(updateDoc(doc(bob().firestore(), gamePath()), {
[`answers.${UID_B}`]: "enc:v1:ZGVm",
}));
});
}
);
// ── entitlement_events/{eventId} ─────────────────────────────────────────────
describe("entitlement_events/{eventId}", () => {

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,

View File

@ -34,13 +34,7 @@ service cloud.firestore {
// must only be performed server-side is denied for all direct client writes.
function isImmutable(fields) {
// Helper to check that certain fields haven't changed during an update
// fields: list of field names that should be immutable
if (resource == null) {
// Create operation - nothing to check
return true;
}
return fields.every(f => resource.data[f] == request.resource.data[f]);
return !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields);
}
function isValidSwipeAction(action) {
@ -57,6 +51,76 @@ service cloud.firestore {
|| category == 'seasonal';
}
function isCiphertext(value) {
return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$');
}
function coupleEncryptionEnabled(coupleId) {
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
}
function isEncryptedAnswerPayload(data) {
return (!('writtenText' in data) || data.writtenText == null || isCiphertext(data.writtenText))
&& (!('selectedOptionIds' in data)
|| (data.selectedOptionIds is list
&& (data.selectedOptionIds.size() == 0
|| (data.selectedOptionIds.size() == 1
&& isCiphertext(data.selectedOptionIds[0])))))
&& (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue));
}
function isStartingEncryptionMigration() {
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
&& request.resource.data.encryptionVersion == 1
&& request.resource.data.wrappedCoupleKey is string
&& request.resource.data.kdfSalt is string
&& request.resource.data.kdfParams is string
&& request.resource.data.encryptionMigrationUsers is map
&& request.resource.data.encryptionMigrationUsers.size() == 0
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'wrappedCoupleKey', 'kdfSalt', 'kdfParams',
'encryptionVersion', 'encryptionMigrationUsers'
]);
}
function isCompletingOwnEncryptionMigration() {
let migrated = request.resource.data.encryptionMigrationUsers;
// Some version-1 couples predate the migration marker. Treat that missing
// map as empty so either partner can safely record their own completion.
let previous = ('encryptionMigrationUsers' in resource.data)
? resource.data.encryptionMigrationUsers
: {};
let changed = migrated.diff(previous).affectedKeys();
let users = resource.data.userIds;
return resource.data.encryptionVersion == 1
&& request.resource.data.encryptionVersion >= 1
&& request.resource.data.encryptionVersion <= 2
&& migrated is map
&& changed.hasOnly([request.auth.uid])
&& migrated[request.auth.uid] == true
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'encryptionVersion', 'encryptionMigrationUsers'
])
&& (request.resource.data.encryptionVersion == 1
|| (migrated[users[0]] == true && migrated[users[1]] == true));
}
function isUpdatingRecoveryWrap() {
return request.resource.data.encryptionVersion >= 1
&& request.resource.data.wrappedCoupleKey is string
&& request.resource.data.kdfSalt is string
&& request.resource.data.kdfParams is string
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'
]);
}
function isUpdatingCoupleRhythm() {
return request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'streakCount', 'lastAnsweredAt'
]);
}
// ── Users ─────────────────────────────────────────────────────────────────
// Each user owns exactly their own document.
// hasPremium is server-only: clients may not write it directly.
@ -127,8 +191,9 @@ service cloud.firestore {
&& request.resource.data.status == 'pending'
&& request.resource.data.expiresAt is timestamp
&& request.time < request.resource.data.expiresAt
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt'])
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt',
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
// Update (accept): proper validation for changing status to accepted.
@ -139,15 +204,15 @@ service cloud.firestore {
// Cannot accept your own invite
&& request.auth.uid != resource.data.inviterUserId
// Must be the acceptor
&& request.resource.data.acceptorUserId == request.auth.uid
&& request.resource.data.acceptedByUserId == request.auth.uid
// Status must change to accepted
&& request.resource.data.status == 'accepted'
// Acceptance timestamp must be set and be a Firestore timestamp
&& request.resource.data.acceptedAt != null
&& request.resource.data.acceptedAt is timestamp
// No other fields should be modified in this update
&& request.resource.data.keys().hasOnly(
['status', 'acceptorUserId', 'acceptedAt', 'coupleId'])
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(
['status', 'acceptedByUserId', 'acceptedAt', 'coupleId'])
// Expired invites cannot be accepted
&& request.time < resource.data.expiresAt
// coupleId, if provided, must point to a real couple that includes the acceptor
@ -173,7 +238,14 @@ service cloud.firestore {
// Must be a member of the couple and include required fields.
allow create: if isSignedIn()
&& request.auth.uid in request.resource.data.userIds
&& request.resource.data.keys().hasAll(['id', 'userIds', 'inviteCode', 'createdAt', 'streakCount'])
&& request.resource.data.keys().hasAll([
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion'
])
&& request.resource.data.encryptionVersion == 2
&& request.resource.data.wrappedCoupleKey is string
&& request.resource.data.kdfSalt is string
&& request.resource.data.kdfParams is string
&& request.resource.data.keys().hasOnly([
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']);
@ -184,13 +256,13 @@ service cloud.firestore {
// - only the explicitly listed mutable fields may change; everything else
// (including currentQuestionId, activePackId, id) is server-only
allow update: if isCouplesMember(coupleId)
&& isImmutable(['userIds', 'inviteCode', 'createdAt'])
&& (resource.data.encryptionVersion == null
|| request.resource.data.encryptionVersion >= resource.data.encryptionVersion)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'streakCount', 'lastAnsweredAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion'
]);
&& isImmutable(['id', 'userIds', 'inviteCode', 'createdAt'])
&& (
isUpdatingCoupleRhythm()
|| isUpdatingRecoveryWrap()
|| isStartingEncryptionMigration()
|| isCompletingOwnEncryptionMigration()
);
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
allow delete: if false;
@ -246,7 +318,10 @@ service cloud.firestore {
// Answers: each user writes their own; both members can read all answers.
match /answers/{userId} {
allow write: if isOwner(userId);
allow create, update: if isOwner(userId)
&& coupleEncryptionEnabled(coupleId)
&& isEncryptedAnswerPayload(request.resource.data);
allow delete: if isOwner(userId);
allow read: if isCouplesMember(coupleId);
}
@ -254,9 +329,13 @@ service cloud.firestore {
match /messages/{messageId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.resource.data.authorUserId == request.auth.uid;
&& coupleEncryptionEnabled(coupleId)
&& request.resource.data.authorUserId == request.auth.uid
&& isCiphertext(request.resource.data.text);
allow update: if isCouplesMember(coupleId)
&& resource.data.authorUserId == request.auth.uid;
&& coupleEncryptionEnabled(coupleId)
&& resource.data.authorUserId == request.auth.uid
&& isCiphertext(request.resource.data.text);
allow delete: if isCouplesMember(coupleId)
&& resource.data.authorUserId == request.auth.uid;
}
@ -378,12 +457,37 @@ service cloud.firestore {
&& request.resource.data.keys().hasAll(['userId', 'questionId', 'answerType', 'createdAt', 'updatedAt'])
&& request.resource.data.userId == request.auth.uid
&& request.resource.data.questionId is string
&& request.resource.data.answerType is string;
&& request.resource.data.answerType is string
&& coupleEncryptionEnabled(coupleId)
&& isEncryptedAnswerPayload(request.resource.data);
allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId
&& request.resource.data.questionId == resource.data.questionId
&& request.resource.data.answerType == resource.data.answerType;
&& request.resource.data.answerType == resource.data.answerType
&& coupleEncryptionEnabled(coupleId)
&& isEncryptedAnswerPayload(request.resource.data);
allow delete: if false;
}
match /{gameCollection}/{sessionId} {
allow read: if isCouplesMember(coupleId)
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'];
allow create: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId)
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel']
&& request.resource.data.answers is map
&& request.resource.data.answers.keys().hasOnly([request.auth.uid])
&& isCiphertext(request.resource.data.answers[request.auth.uid])
&& request.resource.data.keys().hasOnly(['answers', 'categoryName', 'questions']);
allow update: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId)
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel']
&& request.resource.data.answers is map
&& request.resource.data.answers.diff(resource.data.answers).affectedKeys()
.hasOnly([request.auth.uid])
&& isCiphertext(request.resource.data.answers[request.auth.uid])
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['answers']);
allow delete: if false;
}
}