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:
parent
e7b45cc84f
commit
3233c54ab2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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["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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 aead = encryptionManager.requireAead(coupleId)
|
||||
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 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +96,22 @@ 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 ->
|
||||
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(
|
||||
|
|
@ -104,6 +120,8 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
return WheelRevealDoc(
|
||||
categoryName = snap.getString("categoryName") ?: "",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.needsEncryptionUpgrade) {
|
||||
if (state.needsEncryptionUpgrade) {
|
||||
onNavigate(AppRoute.ENCRYPTION_UPGRADE)
|
||||
}
|
||||
}
|
||||
|
||||
HomeContent(
|
||||
state = state,
|
||||
snackbarHostState = snackbarHostState,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,12 @@
|
|||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
"emulators": {
|
||||
"firestore": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8180
|
||||
}
|
||||
},
|
||||
"functions": [
|
||||
{
|
||||
"source": "functions",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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}", () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
|
|
|
|||
154
firestore.rules
154
firestore.rules
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue