inital commit, very early alpha stage
This commit is contained in:
501
ARCHITECTURE_CONTRACTS.md
Normal file
501
ARCHITECTURE_CONTRACTS.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user