clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

docs(site): the communication pass — threat model, homepage claims, status (#134)

FelineStateMachine committed c4ef777
* 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>
10 files changed, +273 -47
MREADME.md9+-
Mdocs/permissions.md4+
Mdocs/seed.md4+-
Mdocs/store-provisioning.md2+-
Mdocs/sync-and-recovery.md2+
Adocs/threat-model.md77+++++++++++++
Mpackage/runtime/data-sink.ts3+-
Mwebsite/docs-manifest.json176+++++++++++++++++++++++++-----
Mwebsite/docusaurus.config.ts24+++-
Mwebsite/src/pages/index.tsx19+++-
M README.md +7 -2
M docs/permissions.md +4 -0
M docs/seed.md +2 -2
@@ -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
M docs/store-provisioning.md +1 -1
M docs/sync-and-recovery.md +2 -0
A docs/threat-model.md +77 -0
@@ -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.
M package/runtime/data-sink.ts +1 -2
M website/docs-manifest.json +144 -32
@@ -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 }
M website/docusaurus.config.ts +20 -4
@@ -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 ],
M website/src/pages/index.tsx +15 -4