163fd6026a
CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData). El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra en un hogar construye el store local sin CloudKit — dos espejos escribiendo los mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por iCloud KV. SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es lo unico que habla con la red. Detecta cambios comparando una huella del contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los borrados van como tombstone: un borrado duro volveria desde cualquier miembro que estuviera sin conexion. Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que dos miembros que abren la misma semana escriban el mismo documento en vez de crear dos, y para que los conflictos se resuelvan por slot y no por semana. Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple, invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al unirse entre llevarse los platos propios o adoptar los del hogar. Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android. Refs #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
97 lines
3.2 KiB
Plaintext
97 lines
3.2 KiB
Plaintext
rules_version = '2';
|
|
|
|
// Hogar compartido de MealMood (issue #33). Ver docs/household-sync.md.
|
|
//
|
|
// Regla de oro: el contenido de un hogar solo lo ven y lo escriben sus
|
|
// miembros. Lo único legible desde fuera es un código de invitación concreto,
|
|
// y solo por id — nunca listando la colección.
|
|
service cloud.firestore {
|
|
match /databases/{database}/documents {
|
|
|
|
function signedIn() {
|
|
return request.auth != null;
|
|
}
|
|
|
|
function uid() {
|
|
return request.auth.uid;
|
|
}
|
|
|
|
function isMember(householdId) {
|
|
return signedIn()
|
|
&& uid() in get(/databases/$(database)/documents/households/$(householdId)).data.memberIds;
|
|
}
|
|
|
|
// Perfil mínimo: a qué hogar pertenece cada persona y su nombre visible.
|
|
match /users/{userId} {
|
|
allow read, write: if signedIn() && uid() == userId;
|
|
}
|
|
|
|
// Códigos de invitación. `get` abierto a cualquier persona autenticada —
|
|
// es la única forma de resolver un código antes de ser miembro. `list`
|
|
// queda prohibido para que nadie pueda barrer códigos ajenos.
|
|
match /invites/{code} {
|
|
allow get: if signedIn();
|
|
allow list: if false;
|
|
allow create, delete: if signedIn()
|
|
&& request.resource.data.createdBy == uid();
|
|
allow update: if false;
|
|
}
|
|
|
|
match /households/{householdId} {
|
|
allow get: if isMember(householdId);
|
|
allow list: if false;
|
|
|
|
// Al crear, la persona queda como dueña y único miembro.
|
|
allow create: if signedIn()
|
|
&& request.resource.data.ownerId == uid()
|
|
&& request.resource.data.memberIds == [uid()];
|
|
|
|
// Dos actualizaciones legítimas:
|
|
// - un miembro cambia datos del hogar (nombre, código de invitación),
|
|
// - alguien de fuera se añade a sí mismo con un código válido.
|
|
allow update: if isMember(householdId) || joiningWithValidInvite(householdId);
|
|
|
|
allow delete: if signedIn()
|
|
&& resource.data.ownerId == uid();
|
|
|
|
function joiningWithValidInvite(householdId) {
|
|
let code = request.resource.data.lastJoinCode;
|
|
let invitePath = /databases/$(database)/documents/invites/$(code);
|
|
return signedIn()
|
|
&& exists(invitePath)
|
|
&& get(invitePath).data.householdId == householdId
|
|
&& get(invitePath).data.expiresAt > request.time
|
|
// Solo puede añadirse a sí misma, y sin tocar a nadie más.
|
|
&& request.resource.data.memberIds == resource.data.memberIds.concat([uid()]);
|
|
}
|
|
|
|
match /members/{memberId} {
|
|
allow read: if isMember(householdId);
|
|
// Cada persona escribe su propia ficha; nadie edita la de otro.
|
|
allow write: if signedIn() && uid() == memberId;
|
|
}
|
|
|
|
// Contenido del hogar: platos, etiquetas, semanas, slots y compra.
|
|
match /dishes/{dishId} {
|
|
allow read, write: if isMember(householdId);
|
|
}
|
|
|
|
match /tags/{tagId} {
|
|
allow read, write: if isMember(householdId);
|
|
}
|
|
|
|
match /weekPlans/{weekKey} {
|
|
allow read, write: if isMember(householdId);
|
|
|
|
match /slots/{slotId} {
|
|
allow read, write: if isMember(householdId);
|
|
}
|
|
}
|
|
|
|
match /shoppingItems/{itemId} {
|
|
allow read, write: if isMember(householdId);
|
|
}
|
|
}
|
|
}
|
|
}
|