502 lines
10 KiB
Markdown
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.
|