Files
comiaunicaty/ARCHITECTURE_CONTRACTS.md

502 lines
10 KiB
Markdown

# 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 <scoped-token>
```
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.