5.7 KiB
Backend Architecture
Role of the backend
The backend is intentionally a dumb encrypted sync target. It does not understand the domain model, object types, or the tree structure. All business logic lives in the frontend. The backend only stores and retrieves opaque encrypted JSON blobs on behalf of an authenticated user.
This design is chosen to support end-to-end encryption (E2EE) and local-first sync in the future. The server never needs to decrypt object data.
Data model
users
| Column | Type | Notes |
|---|---|---|
userId |
VARCHAR(255) | Primary key. UUID or opaque identifier for the user. |
name |
VARCHAR(255) | Display name for the user. |
rootObjectId |
VARCHAR(255) | Points to the user's root object row. |
objects
| Column | Type | Notes |
|---|---|---|
objectId |
VARCHAR(255) | Primary key. UUID identifying the object. |
userId |
VARCHAR(255) | Owner of the object. |
objectData |
TEXT | Encrypted JSON blob (currently stored as plaintext during PoC). |
API contract
Users
POST /users
Create a new user and an empty root object.
Request body:
{
"userId": "string",
"name": "string"
}
Routes use plural REST conventions (/users, /objects).
Response:
{
"status": "created",
"rootObjectId": "string"
}
GET /users/:id
Return the user profile and all associated objects.
Response:
{
"userId": "string",
"name": "string",
"rootObjectId": "string",
"objects": [
{
"objectId": "string",
"objectData": "string"
}
]
}
objects includes the root object and every object owned by the user. The client uses this to rebuild its local state in one request.
PUT /users/:id
Update the user's display name.
Request body:
{
"name": "string"
}
Response:
{
"status": "updated"
}
Note:
userIdis immutable. This endpoint only updates the displayname.
DELETE /users/:id
Delete the user and all associated objects.
Response:
{
"status": "deleted"
}
Objects
POST /objects
Create a new object.
Request body:
{
"objectId": "string",
"userId": "string",
"objectData": "string"
}
objectId is currently supplied by the client to support offline creation. In the future this may be server-generated as part of a more robust user creation flow.
Response:
{
"status": "created",
"objectId": "string"
}
GET /objects/:id
Fetch a single object by ID.
Response:
{
"objectId": "string",
"userId": "string",
"objectData": "string"
}
PUT /objects/:id
Update an object's encrypted data.
Request body:
{
"objectData": "string"
}
Response:
{
"status": "updated"
}
The client is responsible for updating parent childIds arrays when objects are moved or re-parented.
DELETE /objects/:id
Delete a single object.
Response:
{
"status": "deleted"
}
The server does not cascade deletions because it cannot decrypt objectData to know the object's children. The client must walk the tree and delete children explicitly.
Design decisions
- Opaque object data: The server stores
objectDataas an opaque string so it can remain encrypted. It never parses or validates the JSON shape. - Client-owned tree logic: Parent/child relationships are maintained inside the encrypted blobs by the frontend. The backend has no concept of roots, projects, tasks, or notes.
- Bulk user fetch:
GET /users/:idreturns the entire object graph so the frontend can reconstruct state in one request instead of walking the tree with N+1 fetches. - Plaintext PoC: Encryption is planned but not implemented.
objectDatais currently plaintext JSON during early development.
Known gaps and roadmap
| Priority | Item | Notes |
|---|---|---|
| High | Authentication & authorization | Every endpoint must verify the caller owns the requested user/object. Currently absent for PoC simplicity. |
| High | Ownership checks | GET /objects/:id, PUT /objects/:id, and DELETE /objects/:id must reject access to objects owned by another user. |
| Medium | Object versioning / updatedAt |
Needed for safe multi-device sync and conflict detection. |
| Medium | Multi-device conflict resolution | Last-write-wins is acceptable for PoC; eventually needs a real strategy (server wins, CRDTs, manual merge, etc.). |
| Medium | Server-generated object IDs | Currently client-generated to support offline creation; may move to server-generated with auth flow. |
| Low | Orphan garbage collection | Server cannot safely clean up orphans because it cannot decrypt blobs. Client-side cleanupOrphans on startup is planned. |
| Low | Display name updates | PUT /users/:id updates name; userId remains immutable. |
Routes use plural REST conventions (/users, /objects) consistent with the implementation.
Why not a single giant blob?
An alternative considered was storing the user's entire state as one encrypted JSON blob. Rejected because:
- Every small edit would require re-uploading the entire state.
- Avoiding that requires a diff-chain format (operation logs, CRDTs, Merkle trees), which is significantly more complex than per-object blobs.
Per-object blobs strike a balance: the server stays dumb, edits are small, and the sync protocol remains simple.