* docs: neutral posture — rendered docs carry no issue references Documentation addresses app authors in the present tense; history and provenance live in git, issues, and the decision records. * docs(site): the communication pass — threat model, homepage claims, status The threat-model page states in one place what the sync server can and cannot see, what the user holds, the ticket/XSS custody story, and where the lines move (shared-field encryption designed; metadata blindness out of scope under the pinned engine). It is the definition behind the homepage phrase 'the user holds the keys', which stays. Homepage: the offline-reconciliation copy now states what the CRDT actually guarantees — every edit survives, replicas converge, same-field collisions resolve last-writer-wins — and the limits section carries the project status plainly: five canaried engine defects at the pin, not yet for data you cannot afford to lose, and a link to the threat model. README carries the same status. The two current decision records join the site sidebar (numberPrefixParser off so ids match filenames), and sync-and-recovery and permissions cross-link the threat model. Part of #122 (§1-§3) and closes out #123's D section.
- commit
- c4ef777579023db6ec6ee769a57ca23db1ba2379
- parent
- 8642d2989609bbc9481401ca26d2416b72112d25
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 9 | +- |
| M | docs/ | 4 | + |
| M | docs/ | 4 | +- |
| M | docs/ | 2 | +- |
| M | docs/ | 2 | + |
| A | docs/ | 77 | +++++++++++++ |
| M | package/ | 3 | +- |
| M | website/ | 176 | +++++++++++++++++++++++++----- |
| M | website/ | 24 | +++- |
| M | website/ | 19 | +++- |
@@ -63,9 +63,14 @@ The [documentation](https://lofi.host/docs) covers all of this in depth: guides,
6363 lofi is designed for mobile web apps where offline operation is a requirement, not a best-effort6464 enhancement. Keep these current constraints in mind:6565 66-- lofi is an early, pre-1.0 release.66+- lofi is an early, pre-1.0 release; do not yet trust it with data you cannot afford to lose.6767 - The data layer is Jazz 2 alpha and deliberately pinned to a reviewed version68- ([stack and version policy](https://lofi.host/#stack)).68+ ([stack and version policy](https://lofi.host/#stack)). Five known engine defects at the current69+ pin are each documented in a decision record and covered by an automated canary that fails loudly70+ when a version bump changes the behavior.71+- The sync server reads unencrypted columns. The [threat model](https://lofi.host/docs/threat-model)72+ states what the server can and cannot see, what the user holds, and which fields the schema can73+ seal.6974 - The supported browser floors are Android Chrome 148+ and iOS Safari 16.4+.7075 - Recovery is user-controlled: lofi does not retain recoverable account material on the server, so7176 the recovery phrase must be kept safe.@@ -3,6 +3,10 @@
33 `src/permissions.ts` is an author-owned security boundary. It defines which signed local-first44 identity may read or mutate each table; hiding a button in the UI is not a substitute for policy.55 6+> Permissions bound which identities may read or write; they do not hide data from the server that7+> enforces them. See [the threat model](threat-model.md) for what the server can see and8+> [encrypted columns](data-and-ui.md#encrypted-columns) for content it cannot.9+610 ## The starter policy711 812 The generated task app uses creator-owned rows:@@ -22,7 +22,7 @@ targeting Android Chrome 148+ / iOS Safari 16.4+ as hard floors**.
2222 | UI runtime | **Preact 10 islands via `client:only="preact"`** | Skips SSR entirely — correct when all data hydrates from local storage. |2323 | Data | **jazz-tools 2.0 alpha (pinned exact version), OPFS persistent mode** | ~5x faster than the 0.20.x IndexedDB backend. Persistence requires SharedWorker + Web Locks + MessageChannel; fallback is memory-only — hence the hard platform floors below. |2424 | Sync backend | **Jazz Cloud** (managed WebSocket) | Zero-config; self-hosting later changes nothing client-side. |25-| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is required for account-private fields (#126). |25+| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is required for account-private fields. |2626 | PWA | **@vite-pwa/astro v1.2 (Workbox)** | SW's job stays deliberately small: shell precache + push readiness. The SW never touches sync or OPFS. Full prerender = full offline shell for free (precache only covers prerendered routes). |2727 | Cross-island state | **@preact/signals + module-scope singleton client** | React/Preact context can't cross Astro island boundaries; a module singleton + signals sidestep it. No provider component in the public API. |2828 | Toolchain | **Deno 2.9** task runner + server; Astro/Vite under npm-compat | Fallback if npm-compat friction appears: Node for `astro build` only, Deno for everything else. |@@ -160,7 +160,7 @@ exists, which is what makes multi-island apps work.
160160 | 2 | **Astro shell + islands + Deno toolchain** — scaffold workspace, port spikes into `@nzip/lofi` subpath modules, two _separate_ islands sharing one client and live-updating each other (acceptance test for the singleton design); `deno task dev/build/serve` end-to-end with @deno/astro-adapter | 1 wk | Island-boundary state sharing works; npm-compat friction. **Soft kill:** Astro misbehaves under Deno → Node for build, Deno for serve; cap at 1 day |161161 | 3 | **PWA hardening** — Workbox shell precache, manifest, Android install prompt, iOS coach-mark, EU-iOS degrade, `storage.persist()` incl. Safari notification-permission dance, reconnect-on-foreground verified on devices | 1 wk | Offline cold-start (airplane mode → installed PWA → shell + data render); WS reconnect reliability. Failures here are degradable UX, not fatal |162162 | 4 | **Demo app "Ticktick-lite"** — checklists, shared lists via Group invite links (two accounts, two devices), sync badge, offline edits, conflict-free merge test | 1 wk | Is the API pleasant; where the abstraction leaks; invite-revocation gap surfaces concretely |163-| 5 | **PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz (promoted from stretch to required; scope split in issue #126) | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |163+| 5 | **PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |164164 165165 ## Verification166166 @@ -94,7 +94,7 @@ any request is made.
9494 9595 - Table **additions** are the supported merge shape. Evolving columns of already-deployed tables9696 (add/drop/rename) still goes through the app's own migration deploy flow; forwarding those through97- provisioning is follow-on work on [#109](https://github.com/FelineStateMachine/lofi/issues/109).97+ provisioning at enrollment is planned.9898 - `readStoreStatus` compares structurally; it does not verify migration connectivity. A9999 `provisionStore` run (even an `unchanged` one) establishes the connectivity edge for this app's100100 schema, so run it once per app version as part of the opt-in.@@ -74,6 +74,8 @@ Semantics to rely on:
7474 re-pointed.7575 - **One store, one active sink.** Declaring a different sink over an existing one is refused; clear7676 the declaration first. Local data and elections survive clearing.77+- **What the server can see is documented, not implied.** The [threat model](threat-model.md) states78+ the server's view, the user's holdings, and the ticket/XSS custody story in one place.7779 - **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path7880 is what authorizes transport) and never exposed through the session snapshot — `session.sink`7981 carries only the source, host, and label. At rest the declaration persists only as a sealed@@ -0,0 +1,77 @@
1+# What the sync server can see2+3+A lofi app is local-first: without sync, everything lives on the device and this page is moot. The4+moment an account syncs — to a managed deployment or to a self-hosted node — a server holds a copy5+of the data. This page states plainly what that server can and cannot do with it, what the user6+holds that the server never does, and which of those lines move as the encryption surface grows.7+8+## The server's view9+10+The pinned data layer (Jazz 2) uses a trusted-server model: the sync server materializes table11+state, evaluates permission policies, and serves queries. Three consequences follow.12+13+- **Ordinary column values are readable by the store operator.** Permissions bound which14+ _identities_ may read or write rows; they do not hide data from the server that enforces them.15+- **Structure and metadata are always visible**: which tables exist, row identities, who wrote,16+ when, and how much. This holds even for encrypted columns.17+- **Choosing the operator is a real security decision.** Self-hosting (a18+ [lofi-node](https://lofi.host/node)) does not make the server blind; it makes the reader _you_.19+ "Your server reads your data" and "someone else's server reads your data" are different threat20+ models, and lofi treats the choice of sync location as user data, not developer configuration.21+22+## What the user holds23+24+- **The account identity secret.** Accounts are device-local and cryptographic; there is no25+ server-side account to reset or impersonate. The server never holds recoverable account material.26+- **Recovery material**: the recovery phrase, and optionally a recoverable passkey. Losing both27+ loses the account; no operator can restore it, which is the honest half of holding the keys.28+- **The sync location**, enrolled by ticket and revocable at the node.29+- **Sealed local state.** Nothing bearer-shaped sits in browser storage in cleartext: the sync30+ declaration persists as an envelope under a device-bound key, and store-administration capability31+ is either sealed behind the user's passkey or never persisted at all.32+33+## Encrypted columns: content the server cannot read34+35+Fields declared with `s.encryptedText` / `s.encryptedJson` are sealed on the client before they36+enter Jazz. The key derives from the account secret, so every device holding the account decrypts37+and nobody else can, including the store operator. For those fields the server's view degrades to38+"rows of a certain size and cadence exist."39+40+The constraints are mechanical, not policy: an encrypted column cannot be a filter or permission41+target, because the server cannot evaluate what it cannot read; and it is account-private — a row42+shared with another account is undecryptable for them by design, and reads refuse loudly rather than43+return garbage. Details: [encrypted columns](data-and-ui.md#encrypted-columns).44+45+## Tickets: what possession grants46+47+An app-connect ticket is a bearer credential with 256-bit entropy.48+49+- A **sync-scoped** ticket is transport capability. Holding one allows connecting and syncing as the50+ store's transport layer permits; reading data still requires an identity the permissions admit.51+ Revocation is one command at the node and closes live connections within seconds.52+- A **provision-scoped** ticket is store administration by possession. The app therefore refuses to53+ hold one loosely: on enrollment it is exchanged for a derived sync ticket (which becomes the54+ everyday transport credential and dies with its parent on revocation), and the provision original55+ is sealed behind the user's passkey or kept only in memory, with the user's password manager as56+ the durable copy.57+58+**Same-origin script (XSS) is the attacker this custody is shaped around.** Script running on the59+app's origin can use whatever the page can use: it can drive the silent open of the sync60+declaration, so a compromised origin leaks transport capability. What it cannot do silently is61+obtain admin capability — unlocking sealed provision material is a user-verifying passkey prompt, so62+theft degrades from silent to prompted, and a prompt the user did not initiate is the signal to63+decline. At-rest theft (disk images, backups, storage exfiltration) gets the stronger guarantee:64+sealed records are ciphertext without the device key or the passkey.65+66+## Where the lines move67+68+- **Shared-field encryption** is the designed next step: distributing a field key wrapped to each69+ member's account key, stored as ordinary synced data the server cannot open. That demotes the70+ server from passive reader to active attacker for shared fields — it could only attempt key71+ substitution during membership changes, a detectable act rather than silent reading.72+- **Metadata blindness is out of scope** under the pinned engine: a server that evaluates policies73+ and queries must see structure. A fully blind relay is a different architecture and is not74+ claimed.75+76+Read this page as the definition behind the phrase "the user holds the keys": identity, recovery,77+location, and at-rest custody today, with content blindness per field where the schema declares it.@@ -15,8 +15,7 @@
1515 * it does not defend against same-origin script, which can drive the silent1616 * open itself. Boot unseals the record once into module memory1717 * ({ restoreDeclaredSink}), and every synchronous reader answers from18- * that state. The recovery envelope carrying the sink behind the passkey is19- * separate, follow-on work.18+ * that state.2019 *2120 * 2221 */@@ -4,75 +4,187 @@
44 {55 "label": null,66 "items": [7- { "id": "README", "label": "Overview" },8- { "id": "getting-started", "label": "Getting started" }7+ {8+ "id": "README",9+ "label": "Overview"10+ },11+ {12+ "id": "getting-started",13+ "label": "Getting started"14+ }915 ]1016 },1117 {1218 "label": "Guides",1319 "collapsed": false,1420 "items": [15- { "id": "data-and-ui", "label": "Data and UI" },16- { "id": "permissions", "label": "Permissions" },17- { "id": "sync-and-recovery", "label": "Sync and recovery" },18- { "id": "testing", "label": "Testing" },19- { "id": "deployment", "label": "Deployment" },20- { "id": "application-origin-migration", "label": "Origin migration" }21+ {22+ "id": "data-and-ui",23+ "label": "Data and UI"24+ },25+ {26+ "id": "permissions",27+ "label": "Permissions"28+ },29+ {30+ "id": "sync-and-recovery",31+ "label": "Sync and recovery"32+ },33+ {34+ "id": "threat-model",35+ "label": "Threat model"36+ },37+ {38+ "id": "testing",39+ "label": "Testing"40+ },41+ {42+ "id": "deployment",43+ "label": "Deployment"44+ },45+ {46+ "id": "application-origin-migration",47+ "label": "Origin migration"48+ }2149 ]2250 },2351 {2452 "label": "Reference",2553 "items": [26- { "id": "reference/commands", "label": "Commands" },27- { "id": "reference/configuration", "label": "Configuration" },28- { "id": "reference/project-layout", "label": "Project layout" },29- { "id": "reference/access", "label": "Account and access API" },30- { "id": "auth-identity", "label": "Identity and recovery model" },31- { "id": "auth", "label": "Device-auth primitive" }54+ {55+ "id": "reference/commands",56+ "label": "Commands"57+ },58+ {59+ "id": "reference/configuration",60+ "label": "Configuration"61+ },62+ {63+ "id": "reference/project-layout",64+ "label": "Project layout"65+ },66+ {67+ "id": "reference/access",68+ "label": "Account and access API"69+ },70+ {71+ "id": "auth-identity",72+ "label": "Identity and recovery model"73+ },74+ {75+ "id": "auth",76+ "label": "Device-auth primitive"77+ }3278 ],33- "extraLinks": [{ "label": "API reference", "href": "/api" }]79+ "extraLinks": [80+ {81+ "label": "API reference",82+ "href": "/api"83+ }84+ ]3485 },3586 {3687 "label": "Recipes",3788 "indexId": "recipes/README",3889 "items": [39- { "id": "recipes/file-handler", "label": "File handler" },40- { "id": "recipes/launch-handler", "label": "Launch handler" },41- { "id": "recipes/protocol-handler", "label": "Protocol handler" },42- { "id": "recipes/related-app-discovery", "label": "Related-app discovery" },43- { "id": "recipes/scope-extension", "label": "Scope extension" },44- { "id": "recipes/web-share", "label": "Web Share" },45- { "id": "recipes/window-controls-overlay", "label": "Window controls overlay" }90+ {91+ "id": "recipes/file-handler",92+ "label": "File handler"93+ },94+ {95+ "id": "recipes/launch-handler",96+ "label": "Launch handler"97+ },98+ {99+ "id": "recipes/protocol-handler",100+ "label": "Protocol handler"101+ },102+ {103+ "id": "recipes/related-app-discovery",104+ "label": "Related-app discovery"105+ },106+ {107+ "id": "recipes/scope-extension",108+ "label": "Scope extension"109+ },110+ {111+ "id": "recipes/web-share",112+ "label": "Web Share"113+ },114+ {115+ "id": "recipes/window-controls-overlay",116+ "label": "Window controls overlay"117+ }46118 ]47119 },48120 {49121 "label": "Examples: access templates",50122 "items": [51- { "id": "examples/shared", "label": "Direct sharing" },52- { "id": "examples/group", "label": "Fixed-role group" },53- { "id": "examples/policy-conditions", "label": "Policy conditions" }123+ {124+ "id": "examples/shared",125+ "label": "Direct sharing"126+ },127+ {128+ "id": "examples/group",129+ "label": "Fixed-role group"130+ },131+ {132+ "id": "examples/policy-conditions",133+ "label": "Policy conditions"134+ }54135 ]55136 },56137 {57138 "label": "Examples: data modeling",58139 "items": [59- { "id": "examples/collaborative-list", "label": "Collaborative list data model" },60- { "id": "examples/collaborative-sets", "label": "Collaborative sets" },61- { "id": "examples/payloads", "label": "Binary and structured payloads" },62- { "id": "examples/schema-evolution", "label": "Schema evolution" }140+ {141+ "id": "examples/collaborative-list",142+ "label": "Collaborative list data model"143+ },144+ {145+ "id": "examples/collaborative-sets",146+ "label": "Collaborative sets"147+ },148+ {149+ "id": "examples/payloads",150+ "label": "Binary and structured payloads"151+ },152+ {153+ "id": "examples/schema-evolution",154+ "label": "Schema evolution"155+ }63156 ]64157 },65158 {66159 "label": "Decisions",67160 "indexId": "decisions/README",68161 "items": [69- { "id": "decisions/group-creator-authority-alpha53", "label": "Group creator authority" },70- { "id": "decisions/passkey-backup-alpha53", "label": "Passkey backup" }162+ {163+ "id": "decisions/group-creator-authority-alpha53",164+ "label": "Group creator authority"165+ },166+ {167+ "id": "decisions/passkey-backup-alpha53",168+ "label": "Passkey backup"169+ },170+ {171+ "id": "decisions/0001-sink-at-rest-envelope",172+ "label": "At-rest envelope"173+ },174+ {175+ "id": "decisions/0002-convergence-verdict",176+ "label": "Convergence verdict"177+ }71178 ]72179 },73180 {74181 "label": null,75- "items": [{ "id": "troubleshooting", "label": "Troubleshooting" }]182+ "items": [183+ {184+ "id": "troubleshooting",185+ "label": "Troubleshooting"186+ }187+ ]76188 }77189 ]78190 }@@ -183,6 +183,10 @@ const config: Config = {
183183 path: "../docs",184184 routeBasePath: "docs",185185 sidebarPath: "./sidebars.ts",186+ // Decision records are date-ordered with numeric prefixes; keep the187+ // prefix in the document id so ids match filenames everywhere the188+ // manifest is consumed (sidebar, llms corpus, links).189+ numberPrefixParser: false,186190 // `exclude` replaces the plugin defaults, so the `_`-prefix globs187191 // must be restated alongside the repo-only content.188192 exclude: [@@ -274,7 +278,10 @@ const config: Config = {
274278 items: [275279 { label: "docs", to: "/docs" },276280 { label: "api", to: "/api" },277- { label: "github", href: "https://github.com/FelineStateMachine/lofi" },281+ {282+ label: "github",283+ href: "https://github.com/FelineStateMachine/lofi",284+ },278285 { label: "jsr", href: "https://jsr.io/@nzip/lofi" },279286 ],280287 },@@ -286,7 +293,10 @@ const config: Config = {
286293 items: [287294 { label: "docs", to: "/node/docs" },288295 { label: "api", to: "/node/api" },289- { label: "github", href: "https://github.com/FelineStateMachine/lofi-node" },296+ {297+ label: "github",298+ href: "https://github.com/FelineStateMachine/lofi-node",299+ },290300 { label: "jsr", href: "https://jsr.io/@nzip/lofi-node" },291301 ],292302 },@@ -301,7 +311,10 @@ const config: Config = {
301311 items: [302312 { label: "docs", to: "/docs" },303313 { label: "api", to: "/api" },304- { label: "github", href: "https://github.com/FelineStateMachine/lofi" },314+ {315+ label: "github",316+ href: "https://github.com/FelineStateMachine/lofi",317+ },305318 { label: "jsr", href: "https://jsr.io/@nzip/lofi" },306319 { label: "llms.txt", to: "/llms.txt", target: "_blank" },307320 ],@@ -311,7 +324,10 @@ const config: Config = {
311324 items: [312325 { label: "docs", to: "/node/docs" },313326 { label: "api", to: "/node/api" },314- { label: "github", href: "https://github.com/FelineStateMachine/lofi-node" },327+ {328+ label: "github",329+ href: "https://github.com/FelineStateMachine/lofi-node",330+ },315331 { label: "jsr", href: "https://jsr.io/@nzip/lofi-node" },316332 { label: "llms.txt", to: "/node/llms.txt", target: "_blank" },317333 ],@@ -188,8 +188,9 @@ export default function Home(): ReactNode {
188188 <p className="lede band-lede">189189 This is the starter task app the generator gives you. Turn the190190 network off, keep typing on both devices, then turn it back on.191- Nothing is lost and nothing conflicts. That is what the CRDT192- buys you.191+ Edits from both sides survive and every replica converges on192+ the same result: same-field collisions resolve last-writer-wins,193+ everything else merges cleanly. That is what the CRDT buys you.193194 </p>194195 </div>195196 <SyncDemo />@@ -328,11 +329,21 @@ export default function Home(): ReactNode {
328329 <ul className="limits">329330 <li>330331 <b>It is an early alpha release.</b>{" "}331- Expect the surface to move.332+ Expect the surface to move, and do not yet trust it with333+ data you cannot afford to lose.332334 </li>333335 <li>334336 <b>The data layer is Jazz 2 alpha</b>, deliberately pinned335- to a reviewed version.337+ to a reviewed version. Five known engine defects at the338+ current pin are each documented in a decision record and339+ covered by an automated canary that fails loudly when a340+ version bump changes the behavior.341+ </li>342+ <li>343+ <b>The sync server reads unencrypted columns.</b>{" "}344+ <Link to="/docs/threat-model">The threat model</Link>{" "}345+ states what the server can and cannot see, and which346+ fields you can seal.336347 </li>337348 <li>338349 <b>Browser floors are real.</b>{" "}