-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfirestore.rules
More file actions
649 lines (600 loc) · 31.4 KB
/
Copy pathfirestore.rules
File metadata and controls
649 lines (600 loc) · 31.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
rules_version = '2';
// Firestore security rules for UrActor.
//
// The data model is unusual and the rules follow from it: every user owns a
// TOP-LEVEL COLLECTION NAMED AFTER THEIR UID, holding fixed documents (Settings,
// Calendar, Movies, Friends, ...). There is no /users/{uid} parent, so the
// collection id IS the owner's uid and that is what every check compares
// against.
//
// The app is social, so "only the owner may touch their own data" would break
// it. Friends genuinely write into each other's collections: logging a film you
// watched together marks it seen for both of you, shared calendar entries land
// in both calendars, and recommending a title appends to the other person's
// notifications. These rules allow exactly those paths and nothing wider.
//
// Read the KNOWN GAPS section at the bottom before assuming this closes
// everything: two holes cannot be fixed in rules alone because the client
// resolves them by scanning a whole collection.
service cloud.firestore {
match /databases/{database}/documents {
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
function signedIn() {
return request.auth != null;
}
function isSelf(uid) {
return signedIn() && request.auth.uid == uid;
}
// True when the requester appears in the target user's own friends list.
//
// Deliberately reads the TARGET's list rather than the requester's: the
// requester could otherwise write themselves into their own list and gain
// access to a stranger. This costs one document read per evaluation.
function friendOf(uid) {
return signedIn()
&& exists(/databases/$(database)/documents/$(uid)/Friends)
&& request.auth.uid in
get(/databases/$(database)/documents/$(uid)/Friends).data.friends;
}
// The documents a friend is allowed to write, and only these. Each exists
// because a real feature writes it: watched-together marks a title seen for
// both people, shared calendar entries are added to both calendars, and a
// recommendation appends to the recipient's notifications.
function friendWritableDoc(docId) {
return docId in [
'Movies', // watched together -> marks seen for the friend
'TVShows', // same, for shows
'Seen', // the combined seen list
'SeenWith', // records who watched it with whom
'Calendar', // shared "we watched this on this day" entries
'Rewatched', // rewatch counters for a shared viewing
'RewatchedTV',
'Notifications' // recommending a title to a friend
];
}
// The single element is the media id being marked seen, which every write
// path stringifies before sending. Checking it here is only possible
// because a create carries exactly one: on update the added element is
// reachable only as a Set, which cannot be indexed.
function createsListFieldOnly(field) {
return request.resource.data.keys().hasOnly([field])
&& request.resource.data[field] is list
&& request.resource.data[field].size() == 1
&& request.resource.data[field][0] is string;
}
function updatesListFieldOnly(field) {
return request.resource.data.diff(resource.data).affectedKeys()
.hasOnly([field])
&& request.resource.data[field] is list
&& (!(field in resource.data)
|| resource.data[field] is list
&& resource.data[field].toSet()
.difference(request.resource.data[field].toSet()).size() == 0);
}
function createsSeenDocOnly() {
return createsListFieldOnly('Movies') || createsListFieldOnly('TVShows');
}
function updatesSeenDocOnly() {
return updatesListFieldOnly('Movies') || updatesListFieldOnly('TVShows');
}
function createsOneDynamicFieldOnly() {
return request.resource.data.keys().size() == 1;
}
// A rewatch document maps a media id to the number of times it has been
// watched. A friend creating one is recording the first shared viewing, so
// the only honest value is a positive count -- the client writes a literal
// 1, and an increment on a missing key resolves to 1 as well.
//
// keys() returns a LIST, which rules can index, so the single key of a
// create can be named and the value under it read. That is what makes the
// create path checkable where the update path is not.
function createsOneRewatchOnly() {
let id = request.resource.data.keys()[0];
return createsOneDynamicFieldOnly()
&& request.resource.data[id] is int
&& request.resource.data[id] > 0;
}
function updatesOneDynamicFieldOnly() {
let changed = request.resource.data.diff(resource.data);
return changed.affectedKeys().size() <= 1
&& changed.removedKeys().size() == 0;
}
// A calendar is keyed by day, and a day holds the array of entries watched
// that day. A show entry may now also record which season and episode it
// was, both optional: an entry that records neither is exactly what every
// installed client writes, and has to keep being accepted unchanged.
//
// Only the fields are checked, never their presence: requiring a season
// would reject every build in the wild, and requiring a fixed field list
// would reject any field an older build wrote that this one no longer
// does. An episode without a season is refused because the client cannot
// display one and never writes one.
function validCalendarEntry(entry) {
return entry is map
&& (!('season' in entry) || entry.season is int && entry.season > 0)
&& (!('episode' in entry)
|| entry.episode is int && entry.episode > 0 && 'season' in entry);
}
// A friend writing into someone else's calendar is saying "we watched this
// together", and the client says so by naming everyone present -- itself
// included -- in `friends`. Requiring the writer to appear there is the
// closest a rule gets to proving the entry was agreed to rather than
// invented: a friend can no longer drop a viewing into your calendar that
// does not admit who put it there.
//
// Only checked for a friend's write. The owner writes their own calendar
// wholesale and their entries carry no `friends` key at all.
function validFriendCalendarEntry(entry) {
return validCalendarEntry(entry)
&& entry.friends is list
&& request.auth.uid in entry.friends;
}
// Rules cannot iterate a list, so only the first entry of the day can be
// reached. That is enough on create: a friend's first write to a calendar
// is an arrayUnion of the single entry being shared, so the first element
// IS the entry they wrote.
function validCalendarDay(day) {
return request.resource.data[day] is list
&& (request.resource.data[day].size() == 0
|| validFriendCalendarEntry(request.resource.data[day][0]));
}
function createsOneCalendarDayOnly() {
return request.resource.data.keys().size() == 1
&& validCalendarDay(request.resource.data.keys()[0]);
}
// The update path cannot make the same check. diff() hands back the
// affected key as a Set, which rules can measure but not index, so the day
// that changed cannot be named and what landed on it cannot be read. This
// stays where PR #29 left it -- one day touched, none removed -- and the
// per-entry hole is recorded in KNOWN GAPS.
function updatesOneCalendarDayOnly() {
let changed = request.resource.data.diff(resource.data);
return changed.affectedKeys().size() <= 1
&& changed.removedKeys().size() == 0;
}
// SeenWith maps a media id to the people it was watched with. On create the
// whole document is one type holding one media id, so both the key and the
// value under it can be named -- keys() is a list, and a list can be
// indexed. The value has to be the record the app writes and nothing else,
// and it has to name the writer, for the same reason a calendar entry does.
//
// Worth knowing: no shipped client reaches this path. Every friend-targeted
// SeenWith write runs in a transaction that throws unless the document
// already exists (social_service.dart, add_friends_seen_with_popup.dart,
// tvshow_result.dart), so a create here only ever comes from something that
// is not the app. Tightening it costs a legitimate write nothing.
function createsSeenWithTypeOnly(type) {
let media = request.resource.data[type];
let id = media.keys()[0];
return request.resource.data.keys().hasOnly([type])
&& media is map
&& media.keys().size() == 1
&& media[id] is map
&& media[id].keys().hasOnly(['friends'])
&& media[id].friends is list
&& request.auth.uid in media[id].friends;
}
function updatesSeenWithTypeOnly(type) {
let changed = request.resource.data.diff(resource.data);
return changed.affectedKeys().hasOnly([type])
&& request.resource.data[type] is map
&& (!(type in resource.data)
|| resource.data[type] is map
&& request.resource.data[type].diff(resource.data[type])
.affectedKeys().size() <= 1
&& request.resource.data[type].diff(resource.data[type])
.removedKeys().size() == 0);
}
function createsSeenWithDocOnly() {
return createsSeenWithTypeOnly('Movies')
|| createsSeenWithTypeOnly('TVShows');
}
function updatesSeenWithDocOnly() {
return updatesSeenWithTypeOnly('Movies')
|| updatesSeenWithTypeOnly('TVShows');
}
// A recommendation. Every field here is what the client sends; `sender` is
// the one that matters, because until now it was entirely the writer's
// word. Pinning `sender.uid` to the authenticated caller is what stops a
// friend putting a recommendation in your inbox under someone else's name.
//
// `read` must start false: marking it read is the recipient's business, and
// a notification that arrives already read is one you never see.
function validNotification(entry) {
return entry is map
&& entry.sender is map
&& entry.sender.uid == request.auth.uid
&& entry.read == false
&& entry.type is string
&& entry.id is string
&& entry.title is string
&& entry.timestamp is timestamp;
}
// The appended notification can be reached, which the rest of this file
// cannot manage for a dynamic key, because THIS key is not arbitrary: the
// client appends at `String(number of keys already there)`, so the key a
// legitimate write adds is always the current size rendered as a string.
// Reconstructing it turns an unnameable Set member into an index.
//
// That scheme is therefore LOAD-BEARING, and it is chosen somewhere else:
// lib/popups/share.dart on the phone, and appendNotification() in
// functions/index.js on the server. Anything that appends a notification
// under a different key -- a uuid, a timestamp, a random number -- leaves
// this document sparse, and from that moment the key rebuilt below is one
// that already exists. The next write from a build already on someone's
// phone then reads as a change rather than an add and is refused here,
// forever, with the client swallowing the error.
//
// A write that collides with an existing key -- which is what a sparse
// document would produce -- changes a key rather than adding one, and was
// already refused by the changedKeys check below. So nothing that used to
// succeed stops succeeding here.
function appendsOneNotificationOnly() {
let changed = request.resource.data.diff(resource.data);
let appended = string(resource.data.keys().size());
return changed.addedKeys().size() == 1
&& changed.changedKeys().size() == 0
&& changed.removedKeys().size() == 0
&& changed.addedKeys().hasOnly([appended])
&& validNotification(request.resource.data[appended]);
}
function validProgressDocument() {
return (!('Movies' in request.resource.data)
|| request.resource.data.Movies is map)
&& (!('TVShows' in request.resource.data)
|| request.resource.data.TVShows is map);
}
function validFriendCreate(docId) {
return docId == 'Movies' && createsListFieldOnly('Seen')
|| docId == 'TVShows' && createsListFieldOnly('Seen')
|| docId == 'Seen' && createsSeenDocOnly()
|| docId == 'SeenWith' && createsSeenWithDocOnly()
|| docId == 'Calendar' && createsOneCalendarDayOnly()
|| docId == 'Rewatched' && createsOneRewatchOnly()
|| docId == 'RewatchedTV' && createsOneRewatchOnly();
}
function validFriendUpdate(docId) {
return docId == 'Movies' && updatesListFieldOnly('Seen')
|| docId == 'TVShows' && updatesListFieldOnly('Seen')
|| docId == 'Seen' && updatesSeenDocOnly()
|| docId == 'SeenWith' && updatesSeenWithDocOnly()
|| docId == 'Calendar' && updatesOneCalendarDayOnly()
|| docId == 'Rewatched' && updatesOneDynamicFieldOnly()
|| docId == 'RewatchedTV' && updatesOneDynamicFieldOnly()
|| docId == 'Notifications' && appendsOneNotificationOnly();
}
// Only the `friends` array may change, and only by adding or removing the
// requester themselves. This is what lets a friend request be accepted (the
// accepter adds themselves to the sender's list) and a friendship be ended
// from either side, without allowing anyone to rewrite someone else's whole
// social graph.
function onlyTogglesSelfInFriends() {
return request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['friends'])
&& request.resource.data.friends.toSet()
.difference(resource.data.friends.toSet())
.hasOnly([request.auth.uid])
&& resource.data.friends.toSet()
.difference(request.resource.data.friends.toSet())
.hasOnly([request.auth.uid]);
}
// The create-time equivalent of onlyTogglesSelfInFriends(). A user's
// Friends document is created lazily on first write, just like the other
// per-user documents, so the FIRST person to add themselves to someone's
// friends list (accepting the very first request that user ever
// receives) performs a create rather than an update, and there is no
// `resource.data` yet to diff against. The document created this way must
// contain nothing but a `friends` array holding only the writer's own
// uid, which is the create-time version of "only add yourself".
function createsOnlySelfInFriends() {
return request.resource.data.keys().hasOnly(['friends'])
&& request.resource.data.friends.hasOnly([request.auth.uid])
&& request.auth.uid in request.resource.data.friends;
}
// Membership of a shared playlist. `Users` is a list of single-key maps,
// `{ "<uid>": "Owner" | "Approved" }`, which rules cannot iterate — but the
// two role strings are the only ones the app writes, so the pair of
// possible entries can be tested directly.
function playlistMember(data) {
return signedIn() && data.Users.hasAny([
{ request.auth.uid: 'Owner' },
{ request.auth.uid: 'Approved' }
]);
}
function playlistOwner(data) {
return signedIn() && data.Users.hasAny([
{ request.auth.uid: 'Owner' }
]);
}
// ---------------------------------------------------------------------
// Shared collections
//
// Declared BEFORE the per-user wildcard for readability only. Firestore
// rules are additive: a request is allowed if ANY match grants it, so
// ordering carries no meaning. These collection names are therefore also
// matched by /{uid}/{docId} below, which is harmless because no uid can
// equal them and every check there resolves false.
// ---------------------------------------------------------------------
// Reference data on award winners. Read by every client at sign-in and
// written only out of band by the sync script using admin credentials,
// which bypasses these rules entirely.
match /Oscars/{docId} {
allow read: if signedIn();
allow write: if false;
}
// Maps a username to a uid so friends can be found by name.
//
// Readable by any signed-in user because that lookup is the whole point.
// Writes are restricted to claiming your OWN name: without the uid check a
// user could point someone else's username at their own account and
// intercept their friend requests.
match /usernames/{docId} {
allow read: if signedIn();
allow create: if signedIn()
&& request.resource.data.uid == request.auth.uid
&& request.resource.data.keys().hasOnly(['username', 'uid']);
allow update: if signedIn()
&& resource.data.uid == request.auth.uid
&& request.resource.data.uid == request.auth.uid;
allow delete: if signedIn() && resource.data.uid == request.auth.uid;
}
// Shared playlists.
//
// Read is open to any signed-in user, which is wider than it looks. It is
// forced by clients already installed on phones, which find their lists by
// reading the whole collection. joinPlaylist and memberUids now make a
// member-only read possible; flipping it is Stage B, gated on adoption.
// See KNOWN GAPS.
match /Watchlists/{listId} {
allow read: if signedIn();
// The creator must appear as Owner in the document they create, so a
// playlist cannot be created already belonging to someone else.
allow create: if playlistOwner(request.resource.data);
// Members may edit the playlist's contents. Non-members may only join,
// and only by adding themselves: `Users` is the sole field they may
// touch, and the result must contain them.
//
// New clients do not take this path at all -- they call joinPlaylist,
// which runs with admin credentials and is not bound by these rules.
// It stays for the builds that still join by writing to `Users`
// directly, and goes away with the read restriction in Stage B.
allow update: if playlistMember(resource.data)
|| (signedIn()
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['Users'])
&& playlistMember(request.resource.data));
allow delete: if playlistOwner(resource.data);
}
// A title's cast and crew, cached so TMDB is asked for it once ever
// rather than once per viewer.
//
// Entirely server side: recomputePeopleScores fills it and reads it with
// admin credentials, which bypass these rules. Nothing in the app needs
// it -- the screens fetch their own credits in the viewer's language,
// while this holds only ids -- so it is closed to clients outright rather
// than left readable for no reason.
match /Credits/{docId} {
allow read: if false;
allow write: if false;
}
// One record per user saying their library has changed and their
// favourite actor, director and writer scores need recomputing.
//
// Normally written by markPeopleScoresDirty, which runs with admin
// credentials. A client may also mark ITSELF dirty, and that is the only
// thing it may do here: it is how a user whose library has not changed
// since the scores moved off the person page gets a first complete
// ranking without having to watch something to trigger one.
//
// Clearing the flag is deliberately not allowed. Only the worker may say
// the work is done, so a client cannot make itself be skipped, and the
// bookkeeping it writes (lastRunAt, lastError) cannot be forged.
match /PeopleScoreJobs/{uid} {
allow read: if isSelf(uid);
allow create: if isSelf(uid)
&& request.resource.data.keys().hasOnly(['dirty', 'dirtyAt'])
&& request.resource.data.dirty == true;
allow update: if isSelf(uid)
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['dirty', 'dirtyAt'])
&& request.resource.data.dirty == true;
allow delete: if false;
}
// ---------------------------------------------------------------------
// Per-user collections
// ---------------------------------------------------------------------
// Incoming friend requests, stored under the RECIPIENT.
//
// The sender is by definition not yet a friend, so this is the one place a
// stranger may write. The document id is the sender's uid, which pins the
// request to them: nobody can file a request that appears to come from
// someone else, and nobody can spam a user with more than one pending
// request because a second write lands on the same document.
match /{uid}/Friends/FriendRequests/{senderUid} {
allow read: if isSelf(uid) || isSelf(senderUid);
allow create: if isSelf(senderUid)
&& request.resource.data.senderUID == request.auth.uid;
// Accepting or rejecting is the recipient's call; the sender may only
// withdraw by deleting.
allow update: if isSelf(uid);
allow delete: if isSelf(uid) || isSelf(senderUid);
}
match /{uid}/{docId} {
// A single document. Settings is readable by any signed-in user because
// it doubles as the public profile: the username and avatar shown for
// playlist co-members and search results are read straight out of it.
//
// A friend may read EVERY document here, Progress included. Hiding one
// document from a friend is not something this rule can do on its own,
// and trying it took friend features down in production -- see the
// comment on `allow list` directly below, and issue #121.
allow get: if isSelf(uid)
|| docId == 'Settings' && signedIn()
|| friendOf(uid);
// Listing the whole collection is how a friend's profile and calendar
// pages load. Firestore fails a query outright if any single returned
// document is unreadable, so this cannot be narrowed per document while
// the client fetches the collection wholesale.
//
// That sentence is load-bearing and was learned the hard way. A previous
// version excluded `Progress` from what a friend could read, to keep
// watch progress private. Every local check passed -- the emulator, the
// Rules simulator and the whole `firestore-tests` suite -- because none
// of them model production's all-or-nothing query evaluation. Deployed,
// it denied a friend's ENTIRE collection query for any user who had a
// Progress document, so their friends' calendars and watched-together
// lists went blank.
//
// So: a document that a friend may not read cannot live in a collection
// that a friend lists. Making watch progress private needs it moved out
// of this collection, not a narrower rule here.
allow list: if isSelf(uid) || friendOf(uid);
// Per-user documents are created lazily on whatever write touches them
// first, and a friend can legitimately be that first writer: marking a
// title watched-together or sharing a calendar entry both use a merging
// set() and must succeed even when the target has never opened that
// screen and so has no such document yet. Notification writes are
// different: the current client reads the existing document, appends a
// key, then set()s the full result, so a friend may update but not create
// Notifications. Creates stay pinned to the app's per-document shape, so
// a friend cannot bring arbitrary documents or arbitrary payloads into
// existence.
allow create: if (isSelf(uid)
&& (docId != 'Progress' || validProgressDocument()))
|| (friendOf(uid) && friendWritableDoc(docId)
&& validFriendCreate(docId))
// Same lazy-creation trap applies to Friends: accepting the first
// friend request someone ever receives creates their Friends
// document rather than updating it.
|| (signedIn() && docId == 'Friends' && createsOnlySelfInFriends());
allow delete: if isSelf(uid);
allow update: if (isSelf(uid)
&& (docId != 'Progress' || validProgressDocument()))
|| (friendOf(uid) && friendWritableDoc(docId)
&& validFriendUpdate(docId))
// Accepting a request or ending a friendship edits the other person's
// Friends document at the exact moment the two are not (or no longer)
// friends, so it cannot be gated on friendship.
|| (signedIn() && docId == 'Friends' && onlyTogglesSelfInFriends());
}
}
}
// ---------------------------------------------------------------------------
// KNOWN GAPS
//
// All three are client design problems that rules cannot close on their own.
//
// 1. Every signed-in user can still read every playlist.
//
// The server side of this is now done: joinPlaylist (functions/index.js)
// verifies the access code without the code ever reaching the device, and
// syncPlaylistMembers maintains a queryable `memberUids` array so a client
// can ask for its own lists instead of downloading the collection.
//
// What remains is flipping `allow read` on /Watchlists/{listId} from
// `signedIn()` to `playlistMember(resource.data)`, and dropping the
// non-member update branch below that exists only so the old client can
// join by writing to `Users` itself.
//
// That flip is deliberately NOT made yet. Builds already installed on
// people's phones find their playlists by reading the whole collection,
// and restricting read breaks them the moment it ships. It is gated on
// Play Console adoption of a client that uses the function.
//
// 2. Friend writes are constrained to the coarse shape today's installed
// clients already send: watched lists may only grow their known list fields,
// SeenWith may only touch one Movies/TVShows media entry, Calendar and
// rewatch documents may only touch one dynamic key, and Notifications may
// only append one new key to an existing document.
//
// The value inside those keys is now checked wherever the key can be NAMED,
// which is what the rest of this note is about. Rules can index a List but
// not a Set, and diff() hands back affected keys as a Set -- so what is
// reachable is decided by whether a key can be reconstructed some other way.
//
// Reachable, and therefore closed:
//
// - A notification's key is not arbitrary. The client appends at
// `String(number of keys already present)`, so the rule rebuilds it as
// string(resource.data.keys().size()) and reads the value there. That
// pins `sender.uid` to the authenticated caller, which is what stops a
// friend filing a recommendation in your inbox under someone else's name,
// and requires `read` to start false.
// - On CREATE the whole document is the one key being written, and keys()
// is a List, so it can be indexed. A calendar entry must name its writer
// in `friends`, a SeenWith record must be {friends: [...]} naming its
// writer, a rewatch counter must be a positive int, and a watched list
// must hold a media id string.
//
// Still open, and not fixable in rules:
//
// - The UPDATE path for Calendar and SeenWith. The day or media id that
// changed is only ever a Set member, so it cannot be named and what
// landed there cannot be read. A friend updating an existing calendar can
// still put anything inside the entry, including a nonsense season, and a
// friend updating an existing SeenWith can still write an arbitrary
// record for a media id.
// - Whether the values MEAN anything: that a media id is real, that a
// rewatch count is plausible, that the two people actually watched it.
// Rules cannot cross-check a value against a document they were not
// given.
//
// Closing those needs the write to move behind a Cloud Function, which can
// read both users' documents and, more usefully, perform the whole
// both-sides fan-out itself so reciprocity is structural rather than
// checked. joinPlaylist is the precedent. Once a client that writes through
// such a function has Play Console adoption, the friend branches of
// `allow create` and `allow update` above -- and every helper they need --
// can be deleted outright, and this file gets shorter rather than longer.
// That is the same staging every gate in this section uses, and for the
// same reason: a rules deploy applies to every client at once, with no
// staged rollout, so the client has to be ready BEFORE the rules change
// ships.
//
// The recommendation path has taken that route already. recommendTitle
// (functions/index.js) verifies the friendship against the RECIPIENT's own
// list, derives `sender` from the caller's token and their own Settings
// document, and appends inside a transaction -- so a client calling it
// cannot forge a sender at all, rather than being caught trying.
//
// That is a verified ROUTE, not a closed gap. The Notifications branch
// below still stands and builds already on people's phones still write
// through it; nothing closes until that branch is removed and these rules
// are deployed. The Calendar and SeenWith UPDATE paths are still waiting
// for the equivalent function -- a watched-together fan-out -- which is a
// larger piece of work because it writes several documents for both people
// at once.
//
// Be careful about what "ships" means here, because it is not what the
// issues describing this staging assume -- though it is no longer what it
// was. CI does now deploy this file: the release workflow deploys
// `--only functions,firestore:rules,firestore:indexes` on every merge to
// master that touched any of them, so merging a change here does reach
// production, on that merge, applied to every client at once.
//
// For a long time it did not. The release deployed `--only functions`,
// firebase.json named these rules and no automation ever acted on it, and a
// merged change stayed inert until a human ran:
//
// firebase deploy --only firestore:rules --project actordb-cf981
//
// which is still how you deploy one out of band. The consequence worth
// keeping is the opposite of the old one: a rules mistake is live on merge
// rather than parked until someone notices, so the suite in
// firestore-tests/ -- which now runs on the pull request and again in the
// release job -- is what stands between a bad edit here and every phone.
//
// 3. A user can still write their own FavActors, FavDirectors and FavWriters.
//
// recomputePeopleScores owns those three documents now and rewrites them
// whole, so anything a client puts there is corrected on the next run. The
// write is not blocked yet because builds already on people's phones still
// compute a score on the person page and save it; refusing it would make
// every person page they open raise a permission error.
//
// The fix is to add these three ids to a deny list on /{uid}/{docId} once
// Play Console adoption of a client that does not write them is high
// enough -- the same staging as the playlist read restriction above.
// ---------------------------------------------------------------------------