rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function owner(uid) { return request.auth != null && request.auth.uid == uid; } function active(uid) { return owner(uid) && get(/databases/$(database)/documents/users/$(uid)).data.accountState == 'active'; } function bounded(s, n) { return s is string && s.size() <= n; } function unit(n) { return n is number && n >= 0 && n <= 1; } function nullableNumber(n, max) { return n == null || (n is number && n >= 0 && n <= max); } function feeling(n) { return n == null || (n is int && n >= 1 && n <= 5); } function source(s) { return s in ['iphoneCameraPPG', 'appleWatchOptical']; } function stamp(d) { return d.schemaVersion == 1 && d.updatedAt == request.time && (resource == null ? d.createdAt == request.time : d.createdAt == resource.data.createdAt); } function revision(d) { return d.revision is int && (resource == null ? d.revision == 1 : d.revision == resource.data.revision + 1); } function alive(uid, kind, id) { return !exists(/databases/$(database)/documents/users/$(uid)/tombstones/$(kind + '_' + id)); } function profile(d) { return d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','accountState','firstName','birthYear','gender','locale','timeZone']) && stamp(d) && d.accountState == 'active' && bounded(d.firstName,50) && (d.birthYear == null || (d.birthYear is int && d.birthYear >= 1900 && d.birthYear <= request.time.year())) && d.gender in [null,'female','male'] && bounded(d.locale,20) && bounded(d.timeZone,80); } match /users/{uid} { allow read: if owner(uid); allow create: if owner(uid) && profile(request.resource.data); allow update: if active(uid) && profile(request.resource.data); match /onboarding/{id} { allow read: if active(uid); allow create, update: if active(uid) && id == 'current' && stamp(request.resource.data) && request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','stress','sleep','energy','experience','completed','flowVersion']) && request.resource.data.stress in [null,'low','medium','high'] && request.resource.data.sleep in [null,'low','medium','high'] && request.resource.data.energy in [null,'low','medium','high'] && request.resource.data.experience in ['phone','watch'] && request.resource.data.completed is bool && request.resource.data.flowVersion == 1; } match /preferences/{id} { allow read: if active(uid); allow create, update: if active(uid) && id == 'general' && stamp(request.resource.data) && request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','notificationsEnabled','saveMindAndBodyWorkouts','blackScreenEnabled']) && request.resource.data.notificationsEnabled is bool && request.resource.data.saveMindAndBodyWorkouts is bool && request.resource.data.blackScreenEnabled is bool; } match /practiceSettings/{id} { allow read: if active(uid); allow create, update: if active(uid) && id in ['breathing','anchoring','meditation','sleep','wake','coherence'] && stamp(request.resource.data) && request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','durationSeconds','intensity','soundEnabled']) && request.resource.data.durationSeconds is int && request.resource.data.durationSeconds >= 30 && request.resource.data.durationSeconds <= 7200 && unit(request.resource.data.intensity) && request.resource.data.soundEnabled is bool; } match /measurements/{id} { function measurement(d) { return stamp(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','source','deviceId','startedAt','endedAt','durationSeconds','averageBpm','minimumBpm','maximumBpm','estimatedRmssdMs','sdnnMs','quality','coverage','valid','algorithmVersion']) && source(d.source) && bounded(d.deviceId,100) && d.startedAt is timestamp && d.endedAt is timestamp && d.startedAt <= d.endedAt && d.endedAt <= request.time && d.durationSeconds is number && d.durationSeconds > 0 && d.durationSeconds <= 7200 && nullableNumber(d.averageBpm,300) && nullableNumber(d.minimumBpm,300) && nullableNumber(d.maximumBpm,300) && nullableNumber(d.estimatedRmssdMs,5000) && nullableNumber(d.sdnnMs,5000) && unit(d.quality) && unit(d.coverage) && d.valid is bool && bounded(d.algorithmVersion,80); } allow read: if active(uid); allow create: if active(uid) && alive(uid,'measurement',id) && measurement(request.resource.data); } match /measurementAnnotations/{id} { allow read: if active(uid); allow create, update: if active(uid) && alive(uid,'measurement',id) && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id)) && stamp(request.resource.data) && revision(request.resource.data) && request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','feeling','posture','atRest','recentExercise','recentCaffeine','recentMeal','recordedAt']) && feeling(request.resource.data.feeling) && request.resource.data.posture in [null,'seated','standing','lying','unknown'] && request.resource.data.atRest is bool && request.resource.data.recentExercise is bool && request.resource.data.recentCaffeine is bool && request.resource.data.recentMeal is bool && request.resource.data.recordedAt is timestamp && request.resource.data.recordedAt <= request.time; } match /sessions/{id} { function refValid(id) { return id == null || (id is string && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id))); } function session(d) { return stamp(d) && revision(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','practiceId','protocolVersion','deviceId','source','startedAt','endedAt','plannedDurationSeconds','actualDurationSeconds','status','beforeMeasurementId','afterMeasurementId','feelingBefore','feelingAfter']) && d.practiceId in ['breathing','anchoring','meditation','sleep','wake','coherence'] && d.protocolVersion is int && d.protocolVersion >= 1 && bounded(d.deviceId,100) && source(d.source) && d.startedAt is timestamp && d.startedAt <= request.time && (d.endedAt == null || (d.endedAt is timestamp && d.endedAt >= d.startedAt && d.endedAt <= request.time)) && d.plannedDurationSeconds is int && d.plannedDurationSeconds > 0 && d.plannedDurationSeconds <= 7200 && d.actualDurationSeconds is number && d.actualDurationSeconds >= 0 && d.actualDurationSeconds <= 86400 && d.status in ['inProgress','awaitingAfter','completed','cancelled'] && (d.status == 'inProgress' || d.endedAt != null) && refValid(d.beforeMeasurementId) && refValid(d.afterMeasurementId) && feeling(d.feelingBefore) && feeling(d.feelingAfter); } allow read: if active(uid); allow create: if active(uid) && alive(uid,'session',id) && session(request.resource.data); allow update: if active(uid) && alive(uid,'session',id) && session(request.resource.data) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['updatedAt','revision','endedAt','actualDurationSeconds','status','afterMeasurementId','feelingBefore','feelingAfter']) && (resource.data.status == 'inProgress' || (resource.data.status == 'awaitingAfter' && request.resource.data.status in ['awaitingAfter','completed','cancelled'])); } // Reserved server-owned collections. Unimplemented client contracts stay closed. match /{collection}/{id} { allow read: if active(uid) && collection in ['devices','reminders','dailyStats','baselines','entitlements','tombstones']; } } match /protocols/{id}/versions/{version} { allow read: if request.auth != null && resource.data.published == true; } } }