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)
|
# Firebase config (contains project ID, app ID, OAuth client, API key)
|
||||||
app/google-services.json
|
app/google-services.json
|
||||||
functions/node_modules/
|
functions/node_modules/
|
||||||
|
firestore-tests/node_modules/
|
||||||
UI-PLAN.md
|
UI-PLAN.md
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import app.closer.ui.pairing.EmailInviteScreen
|
||||||
import app.closer.ui.pairing.InviteConfirmScreen
|
import app.closer.ui.pairing.InviteConfirmScreen
|
||||||
import app.closer.ui.pairing.PairPromptScreen
|
import app.closer.ui.pairing.PairPromptScreen
|
||||||
import app.closer.ui.pairing.RecoveryScreen
|
import app.closer.ui.pairing.RecoveryScreen
|
||||||
|
import app.closer.ui.pairing.EncryptionUpgradeScreen
|
||||||
import app.closer.ui.dates.DateMatchScreen
|
import app.closer.ui.dates.DateMatchScreen
|
||||||
import app.closer.ui.dates.DateMatchesScreen
|
import app.closer.ui.dates.DateMatchesScreen
|
||||||
import app.closer.ui.dates.DateBuilderScreen
|
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
|
// Wheel / Category Selection
|
||||||
composable(route = AppRoute.CATEGORY_PICKER) {
|
composable(route = AppRoute.CATEGORY_PICKER) {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ object AppRoute {
|
||||||
const val MEMORY_LANE = "memory_lane"
|
const val MEMORY_LANE = "memory_lane"
|
||||||
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
||||||
const val RECOVERY = "recovery"
|
const val RECOVERY = "recovery"
|
||||||
|
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
|
||||||
|
|
||||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||||
const val QUESTION_THREAD =
|
const val QUESTION_THREAD =
|
||||||
|
|
@ -108,7 +109,9 @@ object AppRoute {
|
||||||
Definition(DESIRE_SYNC, "Desire Sync", "play"),
|
Definition(DESIRE_SYNC, "Desire Sync", "play"),
|
||||||
Definition(CONNECTION_CHALLENGES, "Connection Challenges", "play"),
|
Definition(CONNECTION_CHALLENGES, "Connection Challenges", "play"),
|
||||||
Definition(MEMORY_LANE, "Memory Lane", "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(
|
val topLevelRoutes = setOf(
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,15 @@ enum class EncryptionStatus {
|
||||||
RECONCILED_FROM_INVITE,
|
RECONCILED_FROM_INVITE,
|
||||||
/** encryptionVersion == 1 but no local keyset — prompt for recovery phrase. */
|
/** encryptionVersion == 1 but no local keyset — prompt for recovery phrase. */
|
||||||
NEEDS_RECOVERY,
|
NEEDS_RECOVERY,
|
||||||
/** encryptionVersion == 0 (old couple) — operates in plaintext passthrough. */
|
/** encryptionVersion == 0 — this couple must create a key before writing more answers. */
|
||||||
PLAINTEXT_COUPLE
|
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(
|
data class SetupResult(
|
||||||
val handle: KeysetHandle,
|
val handle: KeysetHandle,
|
||||||
val wrapped: RecoveryKeyManager.WrappedKey,
|
val wrapped: RecoveryKeyManager.WrappedKey,
|
||||||
|
|
@ -69,16 +74,30 @@ class CoupleEncryptionManager @Inject constructor(
|
||||||
* Handles inviter reconciliation (flow B′) transparently.
|
* Handles inviter reconciliation (flow B′) transparently.
|
||||||
*/
|
*/
|
||||||
fun checkStatus(couple: Couple): EncryptionStatus {
|
fun checkStatus(couple: Couple): EncryptionStatus {
|
||||||
if (couple.encryptionVersion == 0) return EncryptionStatus.PLAINTEXT_COUPLE
|
if (couple.encryptionVersion == 0) return EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE
|
||||||
if (keyStore.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED
|
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)) {
|
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
|
return EncryptionStatus.NEEDS_RECOVERY
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aeadFor(coupleId: String): Aead? = keyStore.aeadFor(coupleId)
|
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)
|
fun isUnlocked(coupleId: String): Boolean = keyStore.hasKeyset(coupleId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -98,4 +117,23 @@ class CoupleEncryptionManager @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId)
|
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) {
|
fun deleteKeyset(coupleId: String) {
|
||||||
prefs.edit().remove(prefKey(coupleId)).apply()
|
prefs.edit()
|
||||||
|
.remove(prefKey(coupleId))
|
||||||
|
.remove(pendingPhraseKey(coupleId))
|
||||||
|
.apply()
|
||||||
aeadCache.remove(coupleId)
|
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? {
|
fun aeadFor(coupleId: String): Aead? {
|
||||||
aeadCache[coupleId]?.let { return it }
|
aeadCache[coupleId]?.let { return it }
|
||||||
val handle = loadKeyset(coupleId) ?: return null
|
val handle = loadKeyset(coupleId) ?: return null
|
||||||
|
|
@ -83,6 +97,7 @@ class CoupleKeyStore @Inject constructor(
|
||||||
|
|
||||||
private fun prefKey(coupleId: String) = "keyset_$coupleId"
|
private fun prefKey(coupleId: String) = "keyset_$coupleId"
|
||||||
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
|
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
|
||||||
|
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
|
||||||
|
|
||||||
private fun serialize(handle: KeysetHandle): String {
|
private fun serialize(handle: KeysetHandle): String {
|
||||||
val baos = ByteArrayOutputStream()
|
val baos = ByteArrayOutputStream()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package app.closer.crypto
|
package app.closer.crypto
|
||||||
|
|
||||||
import android.util.Base64
|
|
||||||
import com.google.crypto.tink.Aead
|
import com.google.crypto.tink.Aead
|
||||||
|
import java.util.Base64
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ class FieldEncryptor @Inject constructor() {
|
||||||
plaintext.toByteArray(Charsets.UTF_8),
|
plaintext.toByteArray(Charsets.UTF_8),
|
||||||
coupleId.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? =
|
fun encryptNullable(value: String?, aead: Aead, coupleId: String): String? =
|
||||||
|
|
@ -37,7 +37,7 @@ class FieldEncryptor @Inject constructor() {
|
||||||
if (!value.startsWith(PREFIX)) return value
|
if (!value.startsWith(PREFIX)) return value
|
||||||
if (aead == null) return null
|
if (aead == null) return null
|
||||||
return runCatching {
|
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))
|
aead.decrypt(cipher, coupleId.toByteArray(Charsets.UTF_8))
|
||||||
.toString(Charsets.UTF_8)
|
.toString(Charsets.UTF_8)
|
||||||
}.getOrNull()
|
}.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
|
answer: LocalAnswer
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
val date = todayUtcString()
|
val date = todayUtcString()
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"userId" to userId,
|
"userId" to userId,
|
||||||
"questionId" to questionId,
|
"questionId" to questionId,
|
||||||
"answerType" to answer.answerType,
|
"answerType" to answer.answerType,
|
||||||
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
|
"writtenText" to fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId),
|
||||||
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
|
"selectedOptionIds" to if (answer.selectedOptionIds.isNotEmpty())
|
||||||
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
|
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
|
||||||
else answer.selectedOptionIds,
|
else answer.selectedOptionIds,
|
||||||
"scaleValue" to if (aead != null && answer.scaleValue != null)
|
"scaleValue" to if (answer.scaleValue != null)
|
||||||
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
||||||
else answer.scaleValue,
|
else answer.scaleValue,
|
||||||
"createdAt" to answer.createdAt,
|
"createdAt" to answer.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
import app.closer.crypto.RecoveryKeyManager
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
import app.closer.domain.model.Couple
|
import app.closer.domain.model.Couple
|
||||||
import com.google.firebase.firestore.DocumentSnapshot
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
|
|
@ -27,7 +28,7 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
inviterUserId: String,
|
inviterUserId: String,
|
||||||
acceptorUserId: String,
|
acceptorUserId: String,
|
||||||
inviteCode: String,
|
inviteCode: String,
|
||||||
wrappedKey: RecoveryKeyManager.WrappedKey?
|
wrappedKey: RecoveryKeyManager.WrappedKey
|
||||||
): String {
|
): String {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now, wrappedKey)
|
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now, wrappedKey)
|
||||||
|
|
@ -42,7 +43,7 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
acceptorUserId: String,
|
acceptorUserId: String,
|
||||||
inviteCode: String,
|
inviteCode: String,
|
||||||
now: Long,
|
now: Long,
|
||||||
wrappedKey: RecoveryKeyManager.WrappedKey?
|
wrappedKey: RecoveryKeyManager.WrappedKey
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
val data = mutableMapOf<String, Any>(
|
val data = mutableMapOf<String, Any>(
|
||||||
"id" to coupleId,
|
"id" to coupleId,
|
||||||
|
|
@ -51,12 +52,10 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
"createdAt" to now,
|
"createdAt" to now,
|
||||||
"streakCount" to 0
|
"streakCount" to 0
|
||||||
)
|
)
|
||||||
if (wrappedKey != null) {
|
data["encryptionVersion"] = CoupleEncryptionManager.STRICT_ENCRYPTION_VERSION
|
||||||
data["encryptionVersion"] = 1
|
|
||||||
data["wrappedCoupleKey"] = wrappedKey.cipherB64
|
data["wrappedCoupleKey"] = wrappedKey.cipherB64
|
||||||
data["kdfSalt"] = wrappedKey.saltB64
|
data["kdfSalt"] = wrappedKey.saltB64
|
||||||
data["kdfParams"] = wrappedKey.params
|
data["kdfParams"] = wrappedKey.params
|
||||||
}
|
|
||||||
coupleRef(coupleId).set(data)
|
coupleRef(coupleId).set(data)
|
||||||
.addOnSuccessListener { cont.resume(Unit) }
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
|
@ -76,6 +75,47 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.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 =
|
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
userRef(uid).set(
|
userRef(uid).set(
|
||||||
|
|
@ -135,7 +175,9 @@ class FirestoreCoupleDataSource @Inject constructor(
|
||||||
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
|
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
|
||||||
wrappedCoupleKey = getString("wrappedCoupleKey"),
|
wrappedCoupleKey = getString("wrappedCoupleKey"),
|
||||||
kdfSalt = getString("kdfSalt"),
|
kdfSalt = getString("kdfSalt"),
|
||||||
kdfParams = getString("kdfParams")
|
kdfParams = getString("kdfParams"),
|
||||||
|
encryptionMigrationUsers = (get("encryptionMigrationUsers") as? Map<String, Boolean>)
|
||||||
|
?: emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,8 @@ class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
optionIds: List<String>
|
optionIds: List<String>
|
||||||
) {
|
) {
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val value = if (aead != null)
|
val value = fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId)
|
||||||
listOf(fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId))
|
|
||||||
else optionIds
|
|
||||||
doc(coupleId, sessionId)
|
doc(coupleId, sessionId)
|
||||||
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
|
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
|
||||||
.await()
|
.await()
|
||||||
|
|
@ -79,8 +77,12 @@ class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val map = raw as? Map<String, *> ?: return emptyMap()
|
val map = raw as? Map<String, *> ?: return emptyMap()
|
||||||
return map.mapNotNull { (uid, value) ->
|
return map.mapNotNull { (uid, value) ->
|
||||||
val list = (value as? List<*>)?.filterIsInstance<String>() ?: return@mapNotNull null
|
val list = when (value) {
|
||||||
// Encrypted as a single blob; plaintext as a real list
|
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 decrypted = if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) {
|
||||||
val json = fieldEncryptor.decrypt(list[0], aead, coupleId) ?: return@mapNotNull null
|
val json = fieldEncryptor.decrypt(list[0], aead, coupleId) ?: return@mapNotNull null
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
|
||||||
|
|
@ -48,18 +48,14 @@ class FirestoreHowWellDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
answers: List<HowWellRawAnswer>
|
answers: List<HowWellRawAnswer>
|
||||||
) {
|
) {
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val value: Any = if (aead != null) {
|
|
||||||
val json = JSONArray(answers.map {
|
val json = JSONArray(answers.map {
|
||||||
JSONObject().apply {
|
JSONObject().apply {
|
||||||
put("optionId", it.optionId ?: JSONObject.NULL)
|
put("optionId", it.optionId ?: JSONObject.NULL)
|
||||||
put("scale", it.scale ?: JSONObject.NULL)
|
put("scale", it.scale ?: JSONObject.NULL)
|
||||||
}
|
}
|
||||||
}.toString())
|
}.toString())
|
||||||
listOf(fieldEncryptor.encrypt(json.toString(), aead, coupleId))
|
val value = fieldEncryptor.encrypt(json.toString(), aead, coupleId)
|
||||||
} else {
|
|
||||||
answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) }
|
|
||||||
}
|
|
||||||
doc(coupleId, sessionId)
|
doc(coupleId, sessionId)
|
||||||
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
|
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
|
||||||
.await()
|
.await()
|
||||||
|
|
@ -91,7 +87,11 @@ class FirestoreHowWellDataSource @Inject constructor(
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val map = raw as? Map<String, *> ?: return emptyMap()
|
val map = raw as? Map<String, *> ?: return emptyMap()
|
||||||
return map.mapNotNull { (uid, value) ->
|
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
|
// Encrypted: single-element list with JSON blob
|
||||||
if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) {
|
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
|
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 app.closer.domain.model.Invite
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
|
import com.google.firebase.Timestamp
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -30,8 +31,8 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
"code" to code,
|
"code" to code,
|
||||||
"inviterUserId" to inviterUserId,
|
"inviterUserId" to inviterUserId,
|
||||||
"status" to "pending",
|
"status" to "pending",
|
||||||
"createdAt" to now,
|
"createdAt" to Timestamp.now(),
|
||||||
"expiresAt" to now + 24 * 60 * 60 * 1000L,
|
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
|
||||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||||
"kdfSalt" to wrappedKey.saltB64,
|
"kdfSalt" to wrappedKey.saltB64,
|
||||||
"kdfParams" to wrappedKey.params
|
"kdfParams" to wrappedKey.params
|
||||||
|
|
@ -54,9 +55,12 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
inviteeEmail = snap.getString("inviteeEmail"),
|
inviteeEmail = snap.getString("inviteeEmail"),
|
||||||
coupleId = snap.getString("coupleId"),
|
coupleId = snap.getString("coupleId"),
|
||||||
status = snap.getString("status") ?: "pending",
|
status = snap.getString("status") ?: "pending",
|
||||||
createdAt = snap.getLong("createdAt") ?: 0L,
|
createdAt = snap.getTimestamp("createdAt")?.toDate()?.time
|
||||||
expiresAt = snap.getLong("expiresAt") ?: 0L,
|
?: snap.getLong("createdAt") ?: 0L,
|
||||||
acceptedAt = snap.getLong("acceptedAt"),
|
expiresAt = snap.getTimestamp("expiresAt")?.toDate()?.time
|
||||||
|
?: snap.getLong("expiresAt") ?: 0L,
|
||||||
|
acceptedAt = snap.getTimestamp("acceptedAt")?.toDate()?.time
|
||||||
|
?: snap.getLong("acceptedAt"),
|
||||||
acceptedByUserId = snap.getString("acceptedByUserId"),
|
acceptedByUserId = snap.getString("acceptedByUserId"),
|
||||||
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
|
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
|
||||||
kdfSalt = snap.getString("kdfSalt"),
|
kdfSalt = snap.getString("kdfSalt"),
|
||||||
|
|
@ -73,7 +77,7 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
|
||||||
mapOf(
|
mapOf(
|
||||||
"status" to "accepted",
|
"status" to "accepted",
|
||||||
"acceptedByUserId" to acceptorUserId,
|
"acceptedByUserId" to acceptorUserId,
|
||||||
"acceptedAt" to System.currentTimeMillis(),
|
"acceptedAt" to Timestamp.now(),
|
||||||
"coupleId" to coupleId
|
"coupleId" to coupleId
|
||||||
),
|
),
|
||||||
SetOptions.merge()
|
SetOptions.merge()
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
|
|
||||||
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
||||||
val now = FieldValue.serverTimestamp()
|
val now = FieldValue.serverTimestamp()
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
threadsRef(coupleId)
|
threadsRef(coupleId)
|
||||||
.document(threadId)
|
.document(threadId)
|
||||||
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
||||||
|
|
@ -88,11 +88,11 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
"userId" to answer.userId,
|
"userId" to answer.userId,
|
||||||
"questionId" to answer.questionId,
|
"questionId" to answer.questionId,
|
||||||
"answerType" to answer.answerType,
|
"answerType" to answer.answerType,
|
||||||
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
|
"writtenText" to fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId),
|
||||||
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
|
"selectedOptionIds" to if (answer.selectedOptionIds.isNotEmpty())
|
||||||
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
|
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
|
||||||
else answer.selectedOptionIds,
|
else answer.selectedOptionIds,
|
||||||
"scaleValue" to if (aead != null && answer.scaleValue != null)
|
"scaleValue" to if (answer.scaleValue != null)
|
||||||
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
||||||
else answer.scaleValue,
|
else answer.scaleValue,
|
||||||
"createdAt" to now,
|
"createdAt" to now,
|
||||||
|
|
@ -124,14 +124,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
|
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
threadsRef(coupleId)
|
threadsRef(coupleId)
|
||||||
.document(threadId)
|
.document(threadId)
|
||||||
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
|
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
|
||||||
.add(
|
.add(
|
||||||
mapOf(
|
mapOf(
|
||||||
"authorUserId" to message.userId,
|
"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()
|
"createdAt" to FieldValue.serverTimestamp()
|
||||||
)
|
)
|
||||||
).refAwait()
|
).refAwait()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package app.closer.data.remote
|
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.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import org.json.JSONArray
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -27,7 +30,9 @@ data class ThisOrThatAnswers(
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreThisOrThatDataSource @Inject constructor(
|
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) =
|
private fun doc(coupleId: String, sessionId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES)
|
db.collection(FirestoreCollections.COUPLES)
|
||||||
|
|
@ -42,8 +47,10 @@ class FirestoreThisOrThatDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
optionIds: List<String>
|
optionIds: List<String>
|
||||||
) {
|
) {
|
||||||
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
|
val encrypted = fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId)
|
||||||
doc(coupleId, sessionId)
|
doc(coupleId, sessionId)
|
||||||
.set(mapOf("answers" to mapOf(userId to optionIds)), SetOptions.merge())
|
.set(mapOf("answers" to mapOf(userId to encrypted)), SetOptions.merge())
|
||||||
.await()
|
.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,9 +61,7 @@ class FirestoreThisOrThatDataSource @Inject constructor(
|
||||||
if (!snap.exists()) return@runCatching null
|
if (!snap.exists()) return@runCatching null
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val raw = snap.get("answers") as? Map<String, *>
|
val raw = snap.get("answers") as? Map<String, *>
|
||||||
val byUser = raw.orEmpty().mapNotNull { (uid, value) ->
|
val byUser = parseAnswers(raw, coupleId)
|
||||||
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
|
|
||||||
}.toMap()
|
|
||||||
ThisOrThatAnswers(byUser)
|
ThisOrThatAnswers(byUser)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
|
@ -67,11 +72,28 @@ class FirestoreThisOrThatDataSource @Inject constructor(
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val raw = snap.get("answers") as? Map<String, *>
|
val raw = snap.get("answers") as? Map<String, *>
|
||||||
val byUser = raw.orEmpty().mapNotNull { (uid, value) ->
|
val byUser = parseAnswers(raw, coupleId)
|
||||||
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
|
|
||||||
}.toMap()
|
|
||||||
trySend(ThisOrThatAnswers(byUser))
|
trySend(ThisOrThatAnswers(byUser))
|
||||||
}
|
}
|
||||||
awaitClose { reg.remove() }
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -53,18 +55,17 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
questions: List<WheelQuestionRef>,
|
questions: List<WheelQuestionRef>,
|
||||||
answers: List<WheelAnswerEntry>
|
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(
|
val data = mapOf(
|
||||||
"categoryName" to categoryName,
|
"categoryName" to categoryName,
|
||||||
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
|
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
|
||||||
"answers" to mapOf(
|
"answers" to mapOf(userId to fieldEncryptor.encrypt(answerJson.toString(), aead, coupleId))
|
||||||
userId to answers.map {
|
|
||||||
mapOf(
|
|
||||||
"questionId" to it.questionId,
|
|
||||||
"display" to if (aead != null) fieldEncryptor.encrypt(it.display, aead, coupleId) else it.display
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
doc(coupleId, sessionId).set(data, SetOptions.merge()).await()
|
doc(coupleId, sessionId).set(data, SetOptions.merge()).await()
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +96,22 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val rawAnswers = snap.get("answers") as? Map<String, *>
|
val rawAnswers = snap.get("answers") as? Map<String, *>
|
||||||
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
|
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 {
|
(item as? Map<*, *>)?.let {
|
||||||
val rawDisplay = it["display"] as? String ?: ""
|
val rawDisplay = it["display"] as? String ?: ""
|
||||||
WheelAnswerEntry(
|
WheelAnswerEntry(
|
||||||
|
|
@ -104,6 +120,8 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return WheelRevealDoc(
|
return WheelRevealDoc(
|
||||||
categoryName = snap.getString("categoryName") ?: "",
|
categoryName = snap.getString("categoryName") ?: "",
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,10 @@ class CoupleRepositoryImpl @Inject constructor(
|
||||||
saltB64 = invite.kdfSalt ?: error("Missing kdfSalt on invite"),
|
saltB64 = invite.kdfSalt ?: error("Missing kdfSalt on invite"),
|
||||||
params = invite.kdfParams ?: error("Missing kdfParams 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)
|
encryptionManager.unwrapAndStore(coupleId, wrappedKey, recoveryPhrase)
|
||||||
.getOrElse { throw it }
|
.getOrElse { throw it }
|
||||||
}
|
|
||||||
|
|
||||||
coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey)
|
coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ data class Couple(
|
||||||
val streakCount: Int = 0,
|
val streakCount: Int = 0,
|
||||||
val lastAnsweredAt: Long? = null,
|
val lastAnsweredAt: Long? = null,
|
||||||
val activePackId: String? = 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 encryptionVersion: Int = 0,
|
||||||
val wrappedCoupleKey: String? = null,
|
val wrappedCoupleKey: String? = null,
|
||||||
val kdfSalt: 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(
|
HomeContent(
|
||||||
state = state,
|
state = state,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,8 @@ data class HomeUiState(
|
||||||
val primaryAction: HomeAction? = null,
|
val primaryAction: HomeAction? = null,
|
||||||
val secondaryActions: List<HomeAction> = emptyList(),
|
val secondaryActions: List<HomeAction> = emptyList(),
|
||||||
val partnerLeftEvent: Boolean = false,
|
val partnerLeftEvent: Boolean = false,
|
||||||
val needsRecovery: Boolean = false
|
val needsRecovery: Boolean = false,
|
||||||
|
val needsEncryptionUpgrade: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -127,8 +128,14 @@ class HomeViewModel @Inject constructor(
|
||||||
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
val needsRecovery = couple != null &&
|
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
|
||||||
encryptionManager.checkStatus(couple) == EncryptionStatus.NEEDS_RECOVERY
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -138,7 +145,8 @@ class HomeViewModel @Inject constructor(
|
||||||
streakCount = couple?.streakCount ?: 0,
|
streakCount = couple?.streakCount ?: 0,
|
||||||
isPaired = couple != null,
|
isPaired = couple != null,
|
||||||
partnerLeftEvent = false,
|
partnerLeftEvent = false,
|
||||||
needsRecovery = needsRecovery
|
needsRecovery = needsRecovery,
|
||||||
|
needsEncryptionUpgrade = needsEncryptionUpgrade
|
||||||
).withHomeActions()
|
).withHomeActions()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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": {
|
"storage": {
|
||||||
"rules": "storage.rules"
|
"rules": "storage.rules"
|
||||||
},
|
},
|
||||||
|
"emulators": {
|
||||||
|
"firestore": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8180
|
||||||
|
}
|
||||||
|
},
|
||||||
"functions": [
|
"functions": [
|
||||||
{
|
{
|
||||||
"source": "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.
|
// 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
|
// Start it with: firebase emulators:start --only firestore
|
||||||
export default async function () {
|
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,
|
assertFails,
|
||||||
assertSucceeds,
|
assertSucceeds,
|
||||||
initializeTestEnvironment,
|
initializeTestEnvironment,
|
||||||
|
RulesTestContext,
|
||||||
RulesTestEnvironment,
|
RulesTestEnvironment,
|
||||||
} from "@firebase/rules-unit-testing";
|
} from "@firebase/rules-unit-testing";
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { join } from "path";
|
|
||||||
import {
|
import {
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
|
|
@ -33,18 +32,25 @@ import {
|
||||||
|
|
||||||
// ── Test environment ──────────────────────────────────────────────────────────
|
// ── Test environment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROJECT_ID = "closer-rules-test";
|
const PROJECT_ID = process.env.GCLOUD_PROJECT ?? "couples-connect-dev";
|
||||||
let testEnv: RulesTestEnvironment;
|
let testEnv: RulesTestEnvironment;
|
||||||
|
let aliceContext: RulesTestContext;
|
||||||
|
let bobContext: RulesTestContext;
|
||||||
|
let charlieContext: RulesTestContext;
|
||||||
|
let anonContext: RulesTestContext;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
testEnv = await initializeTestEnvironment({
|
testEnv = await initializeTestEnvironment({
|
||||||
projectId: PROJECT_ID,
|
projectId: PROJECT_ID,
|
||||||
firestore: {
|
firestore: {
|
||||||
rules: readFileSync(join(__dirname, "../firestore.rules"), "utf8"),
|
|
||||||
host: "127.0.0.1",
|
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 () => {
|
afterAll(async () => {
|
||||||
|
|
@ -62,12 +68,18 @@ const UID_B = "user_bob";
|
||||||
const UID_C = "user_charlie"; // outsider
|
const UID_C = "user_charlie"; // outsider
|
||||||
const COUPLE_ID = "couple_ab";
|
const COUPLE_ID = "couple_ab";
|
||||||
const COUPLE_DOC = {
|
const COUPLE_DOC = {
|
||||||
|
id: COUPLE_ID,
|
||||||
userIds: [UID_A, UID_B],
|
userIds: [UID_A, UID_B],
|
||||||
inviteCode: "ABC123",
|
inviteCode: "ABC123",
|
||||||
createdAt: 1_000_000,
|
createdAt: 1_000_000,
|
||||||
streakCount: 0,
|
streakCount: 0,
|
||||||
lastAnsweredAt: null,
|
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). */
|
/** Seed documents that rules' helper functions need (e.g. isCouplesMember reads the couple). */
|
||||||
async function seedCouple() {
|
async function seedCouple() {
|
||||||
|
|
@ -85,10 +97,10 @@ async function seedUser(uid: string, coupleId?: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const alice = () => testEnv.authenticatedContext(UID_A);
|
const alice = () => aliceContext;
|
||||||
const bob = () => testEnv.authenticatedContext(UID_B);
|
const bob = () => bobContext;
|
||||||
const charlie = () => testEnv.authenticatedContext(UID_C);
|
const charlie = () => charlieContext;
|
||||||
const anon = () => testEnv.unauthenticatedContext();
|
const anon = () => anonContext;
|
||||||
|
|
||||||
// ── users/{uid} ───────────────────────────────────────────────────────────────
|
// ── users/{uid} ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -222,7 +234,11 @@ describe("invites/{code}", () => {
|
||||||
inviterUserId: UID_A,
|
inviterUserId: UID_A,
|
||||||
code: INVITE_CODE,
|
code: INVITE_CODE,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
wrappedCoupleKey: "wrapped-key",
|
||||||
|
kdfSalt: "salt",
|
||||||
|
kdfParams: "argon2id",
|
||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -234,7 +250,11 @@ describe("invites/{code}", () => {
|
||||||
inviterUserId: UID_A,
|
inviterUserId: UID_A,
|
||||||
code: INVITE_CODE,
|
code: INVITE_CODE,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
wrappedCoupleKey: "wrapped-key",
|
||||||
|
kdfSalt: "salt",
|
||||||
|
kdfParams: "argon2id",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -245,7 +265,11 @@ describe("invites/{code}", () => {
|
||||||
inviterUserId: UID_A,
|
inviterUserId: UID_A,
|
||||||
code: INVITE_CODE,
|
code: INVITE_CODE,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
wrappedCoupleKey: "wrapped-key",
|
||||||
|
kdfSalt: "salt",
|
||||||
|
kdfParams: "argon2id",
|
||||||
coupleId: "injected",
|
coupleId: "injected",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -257,7 +281,11 @@ describe("invites/{code}", () => {
|
||||||
inviterUserId: UID_B,
|
inviterUserId: UID_B,
|
||||||
code: INVITE_CODE,
|
code: INVITE_CODE,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
createdAt: Timestamp.now(),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
wrappedCoupleKey: "wrapped-key",
|
||||||
|
kdfSalt: "salt",
|
||||||
|
kdfParams: "argon2id",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -285,7 +313,7 @@ describe("invites/{code}", () => {
|
||||||
await assertSucceeds(
|
await assertSucceeds(
|
||||||
updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), {
|
updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), {
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
acceptorUserId: UID_B,
|
acceptedByUserId: UID_B,
|
||||||
acceptedAt: Timestamp.now(),
|
acceptedAt: Timestamp.now(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -297,7 +325,7 @@ describe("invites/{code}", () => {
|
||||||
await assertFails(
|
await assertFails(
|
||||||
updateDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), {
|
updateDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), {
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
acceptorUserId: UID_A,
|
acceptedByUserId: UID_A,
|
||||||
acceptedAt: Timestamp.now(),
|
acceptedAt: Timestamp.now(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -328,8 +356,8 @@ describe("couples/{coupleId}", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("member can update allowed fields — allowed", async () => {
|
test("member cannot inject a custom couple field — denied", async () => {
|
||||||
await assertSucceeds(
|
await assertFails(
|
||||||
updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
||||||
someCustomField: "hello",
|
someCustomField: "hello",
|
||||||
})
|
})
|
||||||
|
|
@ -372,6 +400,90 @@ describe("couples/{coupleId}", () => {
|
||||||
test("couple cannot be deleted by client — denied", async () => {
|
test("couple cannot be deleted by client — denied", async () => {
|
||||||
await assertFails(deleteDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`)));
|
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} ──────────────────────────────────
|
// ── couples/{coupleId}/sessions/{sessionId} ──────────────────────────────────
|
||||||
|
|
@ -593,6 +705,19 @@ describe("couples/{coupleId}/question_threads/{threadId}", () => {
|
||||||
|
|
||||||
test("owner can write own answer — allowed", async () => {
|
test("owner can write own answer — allowed", async () => {
|
||||||
await assertSucceeds(
|
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), {
|
setDoc(doc(alice().firestore(), ANSWER_PATH), {
|
||||||
userId: UID_A,
|
userId: UID_A,
|
||||||
questionId: "q1",
|
questionId: "q1",
|
||||||
|
|
@ -641,7 +766,7 @@ describe("couples/{coupleId}/question_threads/{threadId}", () => {
|
||||||
await assertSucceeds(
|
await assertSucceeds(
|
||||||
addDoc(collection(alice().firestore(), MSGS_PATH), {
|
addDoc(collection(alice().firestore(), MSGS_PATH), {
|
||||||
authorUserId: UID_A,
|
authorUserId: UID_A,
|
||||||
text: "Hi",
|
text: CIPHERTEXT,
|
||||||
createdAt: serverTimestamp(),
|
createdAt: serverTimestamp(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -678,36 +803,39 @@ describe("couples/{coupleId}/question_threads/{threadId}", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("member can read messages — allowed", async () => {
|
test("member can read messages — allowed", async () => {
|
||||||
const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
const msgPath = `${MSGS_PATH}/readable-message`;
|
||||||
return addDoc(collection(ctx.firestore(), MSGS_PATH), {
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
await setDoc(doc(ctx.firestore(), msgPath), {
|
||||||
authorUserId: UID_A,
|
authorUserId: UID_A,
|
||||||
text: "Hi",
|
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 () => {
|
test("author can update own message — allowed", async () => {
|
||||||
const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
const msgPath = `${MSGS_PATH}/owned-message`;
|
||||||
return addDoc(collection(ctx.firestore(), MSGS_PATH), {
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
await setDoc(doc(ctx.firestore(), msgPath), {
|
||||||
authorUserId: UID_A,
|
authorUserId: UID_A,
|
||||||
text: "Hi",
|
text: "Hi",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await assertSucceeds(
|
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 () => {
|
test("other member cannot update someone else's message — denied", async () => {
|
||||||
const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
const msgPath = `${MSGS_PATH}/other-message`;
|
||||||
return addDoc(collection(ctx.firestore(), MSGS_PATH), {
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
await setDoc(doc(ctx.firestore(), msgPath), {
|
||||||
authorUserId: UID_A,
|
authorUserId: UID_A,
|
||||||
text: "Hi",
|
text: "Hi",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await assertFails(
|
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} ─────────────────────────────────────────────
|
// ── entitlement_events/{eventId} ─────────────────────────────────────────────
|
||||||
|
|
||||||
describe("entitlement_events/{eventId}", () => {
|
describe("entitlement_events/{eventId}", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"lib": ["ES2020"],
|
"lib": ["ES2020", "DOM"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"resolveJsonModule": 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.
|
// must only be performed server-side is denied for all direct client writes.
|
||||||
|
|
||||||
function isImmutable(fields) {
|
function isImmutable(fields) {
|
||||||
// Helper to check that certain fields haven't changed during an update
|
return !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields);
|
||||||
// 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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidSwipeAction(action) {
|
function isValidSwipeAction(action) {
|
||||||
|
|
@ -57,6 +51,76 @@ service cloud.firestore {
|
||||||
|| category == 'seasonal';
|
|| 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 ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
// Each user owns exactly their own document.
|
// Each user owns exactly their own document.
|
||||||
// hasPremium is server-only: clients may not write it directly.
|
// 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.status == 'pending'
|
||||||
&& request.resource.data.expiresAt is timestamp
|
&& request.resource.data.expiresAt is timestamp
|
||||||
&& request.time < request.resource.data.expiresAt
|
&& request.time < request.resource.data.expiresAt
|
||||||
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt'])
|
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
|
||||||
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt',
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
|
||||||
|
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
|
||||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
|
||||||
|
|
||||||
// Update (accept): proper validation for changing status to accepted.
|
// Update (accept): proper validation for changing status to accepted.
|
||||||
|
|
@ -139,15 +204,15 @@ service cloud.firestore {
|
||||||
// Cannot accept your own invite
|
// Cannot accept your own invite
|
||||||
&& request.auth.uid != resource.data.inviterUserId
|
&& request.auth.uid != resource.data.inviterUserId
|
||||||
// Must be the acceptor
|
// Must be the acceptor
|
||||||
&& request.resource.data.acceptorUserId == request.auth.uid
|
&& request.resource.data.acceptedByUserId == request.auth.uid
|
||||||
// Status must change to accepted
|
// Status must change to accepted
|
||||||
&& request.resource.data.status == 'accepted'
|
&& request.resource.data.status == 'accepted'
|
||||||
// Acceptance timestamp must be set and be a Firestore timestamp
|
// Acceptance timestamp must be set and be a Firestore timestamp
|
||||||
&& request.resource.data.acceptedAt != null
|
&& request.resource.data.acceptedAt != null
|
||||||
&& request.resource.data.acceptedAt is timestamp
|
&& request.resource.data.acceptedAt is timestamp
|
||||||
// No other fields should be modified in this update
|
// No other fields should be modified in this update
|
||||||
&& request.resource.data.keys().hasOnly(
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(
|
||||||
['status', 'acceptorUserId', 'acceptedAt', 'coupleId'])
|
['status', 'acceptedByUserId', 'acceptedAt', 'coupleId'])
|
||||||
// Expired invites cannot be accepted
|
// Expired invites cannot be accepted
|
||||||
&& request.time < resource.data.expiresAt
|
&& request.time < resource.data.expiresAt
|
||||||
// coupleId, if provided, must point to a real couple that includes the acceptor
|
// 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.
|
// Must be a member of the couple and include required fields.
|
||||||
allow create: if isSignedIn()
|
allow create: if isSignedIn()
|
||||||
&& request.auth.uid in request.resource.data.userIds
|
&& 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([
|
&& request.resource.data.keys().hasOnly([
|
||||||
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
|
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
|
||||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']);
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']);
|
||||||
|
|
@ -184,13 +256,13 @@ service cloud.firestore {
|
||||||
// - only the explicitly listed mutable fields may change; everything else
|
// - only the explicitly listed mutable fields may change; everything else
|
||||||
// (including currentQuestionId, activePackId, id) is server-only
|
// (including currentQuestionId, activePackId, id) is server-only
|
||||||
allow update: if isCouplesMember(coupleId)
|
allow update: if isCouplesMember(coupleId)
|
||||||
&& isImmutable(['userIds', 'inviteCode', 'createdAt'])
|
&& isImmutable(['id', 'userIds', 'inviteCode', 'createdAt'])
|
||||||
&& (resource.data.encryptionVersion == null
|
&& (
|
||||||
|| request.resource.data.encryptionVersion >= resource.data.encryptionVersion)
|
isUpdatingCoupleRhythm()
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
|| isUpdatingRecoveryWrap()
|
||||||
'streakCount', 'lastAnsweredAt',
|
|| isStartingEncryptionMigration()
|
||||||
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion'
|
|| isCompletingOwnEncryptionMigration()
|
||||||
]);
|
);
|
||||||
|
|
||||||
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
|
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
@ -246,7 +318,10 @@ service cloud.firestore {
|
||||||
|
|
||||||
// Answers: each user writes their own; both members can read all answers.
|
// Answers: each user writes their own; both members can read all answers.
|
||||||
match /answers/{userId} {
|
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);
|
allow read: if isCouplesMember(coupleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,9 +329,13 @@ service cloud.firestore {
|
||||||
match /messages/{messageId} {
|
match /messages/{messageId} {
|
||||||
allow read: if isCouplesMember(coupleId);
|
allow read: if isCouplesMember(coupleId);
|
||||||
allow create: 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)
|
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)
|
allow delete: if isCouplesMember(coupleId)
|
||||||
&& resource.data.authorUserId == request.auth.uid;
|
&& 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.keys().hasAll(['userId', 'questionId', 'answerType', 'createdAt', 'updatedAt'])
|
||||||
&& request.resource.data.userId == request.auth.uid
|
&& request.resource.data.userId == request.auth.uid
|
||||||
&& request.resource.data.questionId is string
|
&& 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)
|
allow update: if isCouplesMember(coupleId)
|
||||||
&& request.auth.uid == userId
|
&& request.auth.uid == userId
|
||||||
&& request.resource.data.userId == resource.data.userId
|
&& request.resource.data.userId == resource.data.userId
|
||||||
&& request.resource.data.questionId == resource.data.questionId
|
&& 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;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue