FEN Backend Architecture — Self-Hosted (S3-compatible or Nextcloud)
Status: normative design · companion to
fen.mdandcanonical-serialization.md. Decision context: FEN ships no developer-provided backend. The app developer runs nothing. Each group's encrypted log lives in a store the group owner self-hosts and configures once in the app. There are exactly two backend choices, both bring-your-own (BYO): an S3-compatible object store or a Nextcloud instance. The previous design's developer-hosted Garage cluster and itsfen-provisionercontrol plane are removed; all provisioning moves into the client and runs with the owner's own credentials.
0. The Premise (why this design exists)
The app developer must not have to operate a central server. A user downloads the app and must supply their own backend; the group owner/creator supplies it for their group. The single non-negotiable constraint:
- The owner configures a backend once (in setup or settings).
- After that, creating a group and inviting members requires no configuration outside the app — no logging into the storage provider, no folder sharing, no per-group permission setup by hand.
Google Drive, Dropbox, OneDrive, and generic WebDAV all fail this premise: they are either not owner-hostable (a third party remains the central server) or have no API to provision an isolated, writable, account-less per-group share programmatically. The two backends below are the ones that satisfy it.
1. The Trust Model (unchanged)
FEN is designed so the backend cannot read any data.
- Can see: encrypted event/attachment bytes (random-looking), the per-group folder/prefix, per-member
logs/<pubkey>.jsonlfile names (so: group structure + pseudonymous member pubkeys), sizes, timestamps, and client IPs. - Cannot see: group names, member real identities, expense amounts, notes, balances — anything content. The XChaCha20-Poly1305 + Ed25519 layer (unchanged) guarantees this.
The owner's storage credentials never leave the owner's device except as a group-scoped shared credential delivered to members through encrypted, member-facing channels (§5).
2. Two backends behind one seam
All product / ledger / crypto code is backend-agnostic. Two seams matter:
- Data plane —
StorageBackend(fen_sync):probe · list · get · put · delete, with optimistic-concurrency support (ETag / If-Match). Two implementations:S3BackendandNextcloudBackend. - Control plane —
GroupProvisioner(in-app): group create, invite issuance, member removal / group close, and best-effort lifecycle cleanup. Two implementations, one per backend. This replaces the deletedfen-provisionerservice; there is no network control-plane service anymore.
flowchart LR
subgraph client[Member device]
APP[FEN app]
SB[StorageBackend<br/>S3Backend | NextcloudBackend]
GP[GroupProvisioner<br/>owner-credential ops]
end
subgraph owner[Owner-hosted backend]
S3[(S3-compatible<br/>one bucket)]
NC[(Nextcloud<br/>WebDAV + OCS shares)]
end
APP --> SB
APP --> GP
SB -- "SigV4, shared key" --> S3
SB -- "public WebDAV, share token" --> NC
GP -- "owner S3 creds" --> S3
GP -- "owner app-password + OCS API" --> NC
The owner is the only member who holds owner-level credentials; the provisioner runs on the owner's device only.
3. Backend option A — S3-compatible (BYO)
Works against any S3-compatible endpoint — Garage, MinIO, AWS S3, Wasabi, Backblaze, etc. — because S3Backend speaks generic path-style SigV4. This is the app's original storage model.
- Owner configures once:
endpoint,region,bucket,access_key_id,secret_access_key. - One bucket, prefix per group. Groups are path prefixes inside the owner's single bucket. No per-group bucket or per-group key is minted — that would require provider-specific admin/IAM APIs, which are not portable across "any S3." Group creation therefore needs no storage-side call at all (S3 has no real directories; the prefix springs into existence on first
put). - One shared credential per group, carried in the invite (§5). Every member of a group uses the owner-supplied S3 key.
On-storage layout:
(owner bucket)/groups/<group_id>/
roster/roster.jsonl # encrypted roster events
logs/<member_pubkey>.jsonl # one append-only file per author
epochs/<epoch>.json # KeyEpochUpdate payloads
attachments/<sha256(ciphertext)> # encrypted receipt blobs
Isolation note. Because there is one bucket and one shared credential, that credential can read/write every group under the bucket, and it is handed to every member. Content is still protected by end-to-end encryption, but there is no per-group or per-member storage-level isolation. This is an accepted trade of the no-developer-backend premise; see §6.
4. Backend option B — Nextcloud (BYO)
Nextcloud is the one file-sync server whose API lets the owner provision an isolated, writable, account-less per-group share entirely in-app.
- Owner configures once: Nextcloud
baseUrl,username, and an app password (not the login password). - Group create, fully in-app:
MKCOLa per-group folder over WebDAV (/remote.php/dav/files/<user>/<group-folder>/).- Create one password-protected public link on that folder via the OCS Share API (
POST /ocs/v2.php/apps/files_sharing/api/v1/shares,shareType=3,publicUpload=true, permissionsread+create+update+delete).
- Members need no Nextcloud account. They reach the folder through the public WebDAV endpoint (
/public.php/webdav/), authenticating with the share token as username and the share password as password over HTTPS Basic auth. This is the piece plain WebDAV and the consumer clouds cannot do. - One shared credential per group: the
{ baseUrl, shareToken, sharePassword }triple, carried in the invite (§5).
On-folder layout (identical structure to §3, minus the groups/<id>/ prefix — the share is the group root):
(shared folder)/
roster/roster.jsonl
logs/<member_pubkey>.jsonl
epochs/<epoch>.json
attachments/<sha256(ciphertext)>
4.1 Version & capability requirements
The OCS parameters used here (shareType, publicUpload, the permissions bitmask 1=read, 2=update, 4=create, 8=delete, 31=all) and the public WebDAV endpoint are present and backward-compatible across all Nextcloud majors from 12 through current. Because the design uses a single shared public link per group (not per-member links), it does not require multiple-links-per-folder (Nextcloud 15+) and has no meaningful hard version floor.
The practical floor is therefore not a feature version but:
- Security support — require a currently-maintained Nextcloud major at ship time (do not target end-of-life releases).
- Admin-enabled sharing — the instance admin can disable public-link sharing or public upload server-side. This, not the version number, is the common real-world blocker.
The app must detect capabilities at runtime, not hardcode a version: during probe() / "Test connection", query GET /ocs/v2.php/cloud/capabilities, confirm the server version and that public-link sharing + public upload are enabled, and fail the connection test with a clear message otherwise. Any version at or above the floor — including all future releases — works by the forward-compatibility of the OCS surface.
5. Invites (single shared credential, short-TTL, client-enforced)
Both backends deliver access the same way. The group's shared credential (the S3 key of §3, or the Nextcloud share triple of §4) is embedded in the encrypted invite package (&p=, fen.md §2.4), readable only by the intended recipient's key. On join, the member stores the descriptor locally and begins syncing.
- Invites carry a client-enforced TTL (default 30 minutes). The recipient's app refuses an expired invite.
- The credential in the invite is the group's shared credential — there is no separate, narrower per-invite credential, and no server to record single-use redemption.
This is a deliberate, accepted downgrade from the previous provisioner design, which minted single-use, server-expiring invite credentials and atomically recorded the winning pubkey. See §6.
6. Member removal — weak, shared-credential model (explicit, accepted)
This section documents an accepted limitation. It is intentional, not a gap to be fixed.
FEN's no-developer-backend premise removes the only always-online party that could enforce access rules on its own. As a result, member removal uses the weak shared-credential model on both backends, with no fancy per-member scoping and no server-enforced revocation deadline. Removing a member consists of:
-
Forward-secrecy via crypto (authoritative). The organiser rotates the group key epoch (the
KeyEpochUpdateevent) and wraps the fresh epoch key to everyone except the removed member (fen_cryptowrapEpochKeyForMembers), so future events are readable only by remaining members. The removed member is excluded from the new epoch immediately and by construction — they get no wrapped entry, and cannot unwrap another member's pairwise-wrapped entry, so they simply never receive the new key. This is instantaneous and depends only on the organiser's device. It does not revoke the removed member's ability to read past ciphertext they already had access to (unavoidable without deleting their local copy). -
Storage-credential rotation (best-effort, optional). Because every member shares one storage credential, actually revoking the removed member's raw read/write access to the folder/bucket means replacing that shared credential for everyone. The replacement can only be delivered through the storage the old credential still protects, so there is an unavoidable overlap window during which the old credential stays valid until remaining members pick up the new one (opportunistic sync, no push). Killing the old credential immediately would lock out the innocent members too. There is no server to hold or enforce a deadline for this, so completing the rotation is best-effort and driven by the owner's device.
Consequently, the two guarantees the old fen-provisioner was purpose-built to provide are not offered in this design and are explicitly out of scope:
- Atomic single-use invites (server records the one winning redeemer). Replaced by short-TTL client-enforced invites (§5).
- Liveness-independent revocation deadlines (server denies a superseded credential on schedule regardless of any client). Replaced by best-effort, owner-device-driven rotation (above).
Forward secrecy for new activity (step 1) is preserved; hard, timely revocation of raw storage access (step 2) is not guaranteed. Anyone requiring the strong guarantees must run their own always-on component, which is out of scope for FEN.
7. Lifecycle & cleanup (best-effort, client-side)
With no control-plane service, all lifecycle actions run on the owner's device with the owner's credentials, and are best-effort:
| Action | S3-compatible | Nextcloud |
|---|---|---|
| Create group | none (prefix is implicit) | MKCOL folder + OCS create public link |
| Close group | stop syncing; optional purge (below) | OCS delete share (optionally keep folder) |
| Delete group | DeleteObject sweep of groups/<id>/ | OCS delete share + WebDAV DELETE folder |
| Rotate shared credential | new key must be provisioned by the owner at the provider (manual for generic S3) | delete + recreate the public link, re-hand-off via §6 |
| Purge closed groups | optional client setting; owner's app issues deletes after a delay | optional client setting; owner's app deletes share/folder after a delay |
The previous server-side reapers (45-day deletion timer, 6-month inactivity sweep, orphan pruning) are removed. Any equivalent is now an optional, best-effort client behaviour that only runs when the owner's app runs.
8. What was removed from the previous design
- Developer-hosted Garage cluster and the assumption that FEN operates any storage.
services/fen-provisioner(the Go control-plane service, its admin API, auth, rate-limiting, Garage admin client, store, replay, and operator group-stats dashboard).infra/managed-backend(Garage + provisioner +cloudflaredDocker stack, thes3.fenapp.net/api.fenapp.netingress).- Client
ProvisionerClientand the Ed25519-signed control-plane protocol; provisioning is now direct, owner-credentialed backend calls viaGroupProvisioner. - Server-side single-use invites, two-phase key rotation with server-enforced deadlines, and server-side lifecycle reapers — see §6/§7 for what replaces them.
See implementation-plan-selfhost-transition.md for the stepped migration.