feat: iphone app
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user