# ARCHITECTURE_CONTRACTS.md ## Architecture overview The same FastAPI server can operate as: ```text local group server home server both ``` The React frontend talks primarily to one selected home server. The home server stores local memberships and remote server connection configuration. It aggregates structured objects from connected remote group servers. ```text Browser/PWA | | HTTPS/API + HttpOnly session cookie v Home Server | owns home profile, devices, preferences, remote config | fetches remote structured sync using scoped server-side tokens v Remote Group Servers | own canonical groups and group data | expose /.well-known/group-platform.json and /api/sync ``` No server-to-server federation is required. Remote group servers do not know about each other. ## Browser storage policy Allowed in localStorage: - last selected home server URL; - UI preferences; - theme choice; - non-sensitive onboarding flags; - cached non-sensitive IDs for convenience. Not allowed in localStorage: - raw invite tokens after claim; - session tokens; - remote server access tokens; - recovery tokens; - admin credentials. Use HttpOnly cookies for sessions. Use server-side storage for remote connection credentials. ## Core entity relationships ```text HomeProfile 1..n HomeDevice HomeProfile 1..n RecoveryMethod HomeProfile 1..n RemoteServerConnection HomeProfile 0..n Member via Member.home_profile_id Group 1..n Member Group 1..n MemberInvite Group 1..n Announcement Group 1..n Event Event 1..n EventRsvp Group 1..n Task Group 1..n Poll Poll 1..n PollOption PollOption 1..n PollVote Group 1..n FileAsset Group 1..n Thread Thread 1..n Message Member 1..n MemberDevice Member 1..n ActionItem Member 1..n Notification RemoteServerConnection 1..n RemoteMembership RemoteServerConnection 1..n RemoteSyncCursor RemoteServerConnection 1..n RemoteCachedObject ``` A `Member` can exist without a `HomeProfile`. This is required for accountless invite-link joining. Later, a member may be attached to a home profile. ## Suggested SQLAlchemy fields Use exact names where practical. ### HomeProfile ```text id: UUID primary_display_name: string created_at: datetime updated_at: datetime last_seen_at: datetime nullable status: active/disabled ``` ### HomeDevice ```text id: UUID home_profile_id: UUID nullable member_id: UUID nullable label: string user_agent_summary: string created_at: datetime last_seen_at: datetime revoked_at: datetime nullable trust_level: invite_member/claimed_browser/verified/passkey_ready ``` ### RecoveryMethod ```text id: UUID home_profile_id: UUID kind: email/phone/recovery_code/passkey value_hash: string nullable display_hint: string verified_at: datetime nullable created_at: datetime ``` ### Session ```text id: UUID home_profile_id: UUID nullable member_id: UUID nullable home_device_id: UUID nullable member_device_id: UUID nullable csrf_token_hash: string expires_at: datetime created_at: datetime revoked_at: datetime nullable ``` ### Group ```text id: UUID server_origin: string name: string description: text visibility: private/public/listed legacy_channel_status: none/transition/legacy transition_deadline: date nullable created_at: datetime updated_at: datetime archived_at: datetime nullable ``` ### Member ```text id: UUID group_id: UUID home_profile_id: UUID nullable display_name: string role: owner/admin/moderator/member/guest status: invited/opened/joined/verified/suspended/left joined_at: datetime nullable last_seen_at: datetime nullable notification_enabled_at: datetime nullable ``` ### MemberInvite ```text id: UUID group_id: UUID member_id: UUID nullable created_by_member_id: UUID label: string scope: specific_member/open_seat/admin_invite permission_role: guest/member/admin plain_token_display_once: not persisted token_hash: string expires_at: datetime nullable max_uses: int use_count: int consumed_at: datetime nullable revoked_at: datetime nullable created_at: datetime ``` ### Announcement ```text id: UUID group_id: UUID author_member_id: UUID title: string body: text priority: normal/urgent official: bool requires_ack: bool created_at: datetime updated_at: datetime ``` ### Event ```text id: UUID group_id: UUID created_by_member_id: UUID title: string description: text nullable starts_at: datetime ends_at: datetime nullable location_name: string nullable location_address: string nullable rsvp_required: bool changed_at: datetime nullable created_at: datetime updated_at: datetime ``` ### EventRsvp ```text id: UUID event_id: UUID member_id: UUID status: yes/no/maybe/unknown note: text nullable updated_at: datetime ``` ### Task ```text id: UUID group_id: UUID created_by_member_id: UUID assigned_to_member_id: UUID nullable title: string description: text nullable due_at: datetime nullable status: open/done/cancelled created_at: datetime updated_at: datetime ``` ### Poll / PollOption / PollVote ```text Poll: id, group_id, title, description, closes_at, status, created_by_member_id, created_at PollOption: id, poll_id, label, position PollVote: id, poll_id, option_id, member_id, created_at ``` ### FileAsset ```text id: UUID group_id: UUID uploaded_by_member_id: UUID filename_original: string filename_stored: string content_type: string size_bytes: int storage_path: string visibility: members/admins/public_link created_at: datetime ``` ### Thread / Message ```text Thread: id, group_id, title, kind discussion/question/archive, created_by_member_id, created_at, updated_at Message: id, thread_id, author_member_id, body, created_at, edited_at nullable, deleted_at nullable ``` ### ActionItem ```text id: UUID home_profile_id: UUID nullable member_id: UUID nullable remote_connection_id: UUID nullable source_type: local/remote source_server_origin: string source_group_id: string source_group_name: string type: rsvp_required/vote_required/task_assigned/event_changed/file_ack/direct_mention/admin_request status: open/done/dismissed priority: low/normal/high/urgent title: string summary: text object_type: event/task/poll/file/announcement/message object_id: string due_at: datetime nullable created_at: datetime updated_at: datetime ``` ### RemoteServerConnection ```text id: UUID home_profile_id: UUID server_origin: string server_name: string api_base: string protocol_version: string capabilities_json: json access_token_encrypted: string status: active/error/revoked last_sync_at: datetime nullable last_error: text nullable created_at: datetime updated_at: datetime ``` ### RemoteCachedObject ```text id: UUID remote_connection_id: UUID object_type: action/event/announcement/file/thread remote_id: string group_remote_id: string group_name: string payload_json: json updated_at_remote: datetime nullable cached_at: datetime ``` ## API contracts Use JSON. Use ISO 8601 datetimes. Use consistent error response: ```json { "error": { "code": "permission_denied", "message": "You do not have permission to do that.", "details": {} } } ``` ### Invite preview ```http GET /api/join/{token}/preview ``` Response: ```json { "group": { "id": "...", "name": "FC Kreuzberg U12 Parents", "description": "Planning, matches, files, and announcements." }, "invite": { "label": "Parent invite", "expires_at": null, "role": "member" }, "preview": { "announcements": [], "events": [] } } ``` ### Claim invite ```http POST /api/auth/invite/{token}/claim ``` Body: ```json { "display_name": "Anna Müller", "device_label": "iPhone Safari" } ``` Response sets session cookie and returns: ```json { "member": {}, "group": {}, "next_steps": ["save_access", "enable_notifications"] } ``` ### Home dashboard ```http GET /api/home ``` Response: ```json { "profile": {}, "sections": { "needs_me": [], "today": [], "changed": [], "official_updates": [], "catch_up": [] }, "connections": [] } ``` ### Sync endpoint ```http GET /api/sync?since=cursor Authorization: Bearer ``` Response: ```json { "cursor": "next-cursor", "server_time": "2026-06-29T10:00:00Z", "actions": [], "events": [], "announcements": [], "files": [], "threads": [] } ``` ## Remote aggregation behavior The home server stores a cursor per remote connection. Sync should be idempotent. Remote cached objects should include enough payload to render in the Home dashboard without live remote calls every time. When a user performs a write action on a remote object, the MVP can open the remote group page in a new tab if write-through is not implemented. Implement write-through for RSVP if feasible. ## Derived action logic Generate local action items from structured state: - Event with `rsvp_required=true` and no RSVP by current member → `rsvp_required`. - Task assigned to current member and status `open` → `task_assigned`. - Poll open with no vote by current member → `vote_required`. - Event changed after member's last seen time → `event_changed`. - Announcement requires acknowledgement and no acknowledgement exists → `file_ack` or future `announcement_ack`. It is acceptable to recompute on request in MVP, but persist/cache for remote sync. ## Permissions contract Implement a simple helper: ```python require_role(member, group, min_role="admin") can(member, action, resource) ``` Role hierarchy: ```text guest < member < moderator < admin < owner ``` Make role checks visible in route handlers or service methods. ## Development remote-server demo Provide a way to run two local instances: ```text home server: http://localhost:8000 remote server: http://localhost:8001 ``` Options: - docker-compose service duplication; or - README command using different ports and DB paths. Seed each with distinct server names and groups. Show how to create a remote connection token on server 8001 and connect it from server 8000. ## Production limitations to document Document these limitations in README: - Dev email sends to console unless SMTP configured. - Passkey implementation may be development/pluggable if not fully implemented. - Web push may be a future adapter. - No full federation. - No end-to-end encryption. - Remote sync tokens require secure secret management in production. - File storage is local filesystem in dev; production should use S3-compatible storage or mounted volume.