forked / Architecture

ADR-038: Deployment-Selected Sync Mode, Login-Only Self-Hosting, and Account-Based forked Cloud

Status: Accepted Date: 2026-10-18

Context

The PWA is MIT-licensed and public. Self-hosters want one free, simple backend. A shared, operated service needs accounts, plans, device management, and billing, which are operator concerns and not part of the public client. Asking users to pick a backend would ask a question only the operator can answer.

Quality drivers (Section 1.2): usability and learnability (one flow per user, few fields), privacy and ownership (self-hosting stays free of accounts), operability for self-hosters (implicit address and database, few moving parts and secrets), and a small, generic public client.

Decision

  1. The deployment chooses the mode, never the user. A non-empty gatewayUrl in GET /forked-config.json (or VITE_FORKED_GATEWAY_URL in development builds) means forked cloud mode. Otherwise the app is in self-hosted mode. The announcement is cached in localStorage so an offline start knows its mode. The Sync page shows exactly one flow. There is one image and no build variant; without an announcement there is no cloud default.
  2. Self-hosted mode is login-only and gateway-free. The reference stack is the root compose file; any CouchDB-compatible server works. The database is the constant forked; the CouchDB address is the announced couchdbUrl (default <origin>/couchdb). The user enters a user name and password, stored as SelfHostedLogin. Connecting first checks GET <couchdb>/forked and stores the login only on success. Authentication is always Basic auth with browser credentials omitted. A 401 or 403 stops sync. There are no accounts.
  3. Forked cloud mode is account-based; accounts live on the operator’s website. Sign-in is by magic link on the website. The PWA offers:
    • Create account (device approval): the PWA requests an approval, shows the returned code, opens verificationUrl in the system browser and polls until approved, denied, or expired. On approval the gateway sets the session cookie in the polling response; the PWA verifies the session, stores a GatewayEnrollment, and starts sync. The signed-in user compares the code and taps “Connect this device” on the website.
    • Join with QR or pairing code for a further device, Add another device, Manage account (<gatewayUrl>/account), and Disconnect.
    • The entry point is Settings > Sync only; there is no first-run screen.
  4. The gateway is not in this repository. The operator’s gateway implements its own security requirements; only the client contract is public (Section 7.3). Accounts, plans, device limits, the device list, and billing are not in the PWA, and it sends nothing to a gateway before the user acts.
  5. PWA obligations in cloud mode: the sync token is held in memory only, a device pins the database of its first token, a 401 from the token endpoint stops sync with local data kept, invites carry no backend address, and devices join inside the installed app by live QR scan or typeable pairing code. Details: Section 6.2, Section 6.4.

Consequences

  • One flow per deployment: simpler UI, fewer states to test, no “which one am I” question. Two modes remain to maintain, but they never meet in one UI.
  • Self-hosting is CouchDB, the PWA, and a small proxy; secrets are the CouchDB admin password and one sync password. No server code in the public repository.
  • Cloud mode adds an email address as personal data on the operator side, a stretch of the Privacy goal; self-hosting stays accountless and the PWA stores no email.
  • The operator must provide magic-link sign-in, the approval page behind verificationUrl, an account page, the approval API, and capacity for polling. Its security is not checkable from this repository and the contract can drift from the client.
  • Self-hosters have no invites or QR codes: each device needs the login entered by hand.
  • A long-lived password sits in localStorage, mitigated by the strict CSP (ADR-026); it is never logged or synced.
  • No per-device revocation for self-hosters: changing the CouchDB password locks out every device. One CouchDB user per device is an operator workaround.
  • A CouchDB behind cookie-based proxy or SSO auth works only if it also accepts Basic auth.
  • A device served by an origin of the other mode ignores its stored record of the first mode; local data is untouched.
  • Abuse limits and the admin-account caveat of the proxied path are in Section 7.1.

Options Considered

  • User-chosen mode switch in the UI. Rejected: users cannot tell which backend their household has, a self-hoster would see cloud advertising, a cloud user could point data at an unmanaged CouchDB, and the CSP and announced origins already fix what a deployment may reach.
  • Gateway in the public repository. Rejected: it brings a multi-service stack, several secrets, and server code for features (invites, device list) a single household rarely needs, and turns the public extension API into a compatibility promise.
  • Build-time flag (separate image or Vite variant for the operated service). Rejected: two artifacts to build, test, and cache in the service worker; the runtime gatewayUrl toggle gives one image and lets any operator announce a gateway.
  • Plain CouchDB behind nginx basic auth only, without CouchDB users in the PWA. Rejected: browsers cache Basic credentials unpredictably in fetch, and cookie-based proxy or SSO auth does not fit the login-only flow.
  • Anonymous households with a setup code. Rejected: a household with no owner identity cannot be recovered, listed, billed, or deleted by its members, and the setup code is a shared secret to hand around.
  • Named accounts managed inside the PWA (registration, email, plans, device list). Rejected: it puts operator concerns and commercial features into the public client; the website already exists for sign-in and billing.
  • Website pairing-code handoff (the website shows a code, the user types it into the PWA). Rejected: the user types a secret into the app and a second code path is needed next to invites.
  • Redirect-with-link handoff (the website redirects back to the PWA with a token in a link). Rejected: on iPhone a link opened outside the installed PWA lands in Safari, whose storage is separate, so the session would be created in the wrong context. Polling with a cookie set on the PWA’s own request avoids it.

View source on GitHub