|
| 1 | +# remotestorage — a remoteStorage server over a pod |
| 2 | + |
| 3 | +[remoteStorage](https://remotestorage.io/) |
| 4 | +([draft-dejong-remotestorage-22](https://datatracker.ietf.org/doc/html/draft-dejong-remotestorage-22)) |
| 5 | +as a #206 loader plugin — the out-of-tree port of a feature JSS bundles in |
| 6 | +core (`src/remotestorage.js`, always-on at `/storage/:user/*`) and that |
| 7 | +issue #564's migration list names as a plugin candidate. Every rS document |
| 8 | +is a plain pod resource at `/<user>/remotestorage/<category>/<path>`, |
| 9 | +reached over loopback with the caller's own credentials, so real WAC — not |
| 10 | +this plugin — decides every read and write. |
| 11 | + |
| 12 | +```js |
| 13 | +import { createServer } from 'javascript-solid-server/src/server.js'; |
| 14 | + |
| 15 | +const server = createServer({ |
| 16 | + idp: true, |
| 17 | + plugins: [{ |
| 18 | + id: 'remotestorage', |
| 19 | + module: './plugins/remotestorage/plugin.js', |
| 20 | + prefix: '/remotestorage', |
| 21 | + config: { |
| 22 | + baseUrl: 'https://pod.example', // required: public origin |
| 23 | + loopbackUrl: 'http://127.0.0.1:3000', // required: how to reach the host |
| 24 | + // dataDir: 'remotestorage', // pod subfolder for the rS tree |
| 25 | + // claimWellKnown: false, // stand down from /.well-known/webfinger |
| 26 | + }, |
| 27 | + }], |
| 28 | +}); |
| 29 | +``` |
| 30 | + |
| 31 | +## Surface |
| 32 | + |
| 33 | +| route | what | |
| 34 | +|---|---| |
| 35 | +| `GET/HEAD/PUT/DELETE <prefix>/<user>/<category>/<path>` | the storage API → loopback LDP at `/<user>/remotestorage/<category>/<path>`, Authorization forwarded | |
| 36 | +| `GET <prefix>/<user>/<folder>/` | rS folder description (`{"@context":"…/folder-description","items":{…}}`) from a loopback container listing, per-item host ETags | |
| 37 | +| `POST <prefix>/token` | token bridge: `{username,password}` → the host's `/idp/credentials` → the pod bearer as the rS token | |
| 38 | +| `GET <prefix>/webfinger?resource=acct:…` | the rS JRD (contract-safe mount) | |
| 39 | +| `GET /.well-known/webfinger` | the same JRD at the real discovery location — claimed **unguarded** (see Findings #1); disable with `claimWellKnown: false` | |
| 40 | + |
| 41 | +Anonymous `GET`/`HEAD` on `…/public/…` paths work iff the pod grants public |
| 42 | +read (the anonymous loopback request decides — no plugin-side ACL logic). |
| 43 | +Conditional requests (`If-Match`, `If-None-Match`, incl. `*`) are honored on |
| 44 | +documents by forwarding them to the host, and on folders plugin-side. |
| 45 | + |
| 46 | +## What maps / what doesn't |
| 47 | + |
| 48 | +Maps cleanly: |
| 49 | + |
| 50 | +- **Documents are pod resources.** PUT/GET/DELETE round-trip byte-for-byte; |
| 51 | + the ETags rS clients sync on are the host's own LDP ETags, passed through. |
| 52 | +- **Access control is WAC.** The rS notion "the module folders this token |
| 53 | + may touch" degrades to "whatever the pod's ACLs allow this agent", |
| 54 | + which is strictly more expressive; `public/` anonymity falls out of a |
| 55 | + normal public-read `.acl`. |
| 56 | +- **Conditional writes.** The host's LDP implements `If-Match` / |
| 57 | + `If-None-Match` on PUT and DELETE, so the rS concurrency contract is the |
| 58 | + host's, not a reimplementation (Findings #2). |
| 59 | + |
| 60 | +Deviations: |
| 61 | + |
| 62 | +- **No OAuth implicit-grant dialog.** rS clients expect to redirect a |
| 63 | + browser to an authorize UI scoped per module (`documents:rw`). A login UI |
| 64 | + is product-scale; this plugin ships the mastodon/-style shortcut instead: |
| 65 | + `POST <prefix>/token` bridges username+password to the host's |
| 66 | + `/idp/credentials` and returns the pod bearer verbatim. The JRD's |
| 67 | + `rfc6749#section-4.2` property points at that endpoint, so a stock rS |
| 68 | + widget's redirect will not find a form there. Scope strings are not |
| 69 | + interpreted at all — WAC governs instead. |
| 70 | +- **WebFinger under the plugin's own prefix** (`<prefix>/webfinger`) is the |
| 71 | + contract-safe mount; the real `/.well-known/webfinger` is claimed |
| 72 | + by-luck and collides with any other webfinger owner (Findings #1). |
| 73 | +- **Folder ETags are plugin-derived** (hash of the items map), not host |
| 74 | + ETags — measured reason in Findings #3, depth limitation in Findings #6. |
| 75 | +- **Content types don't round-trip** for extension-less documents |
| 76 | + (Findings #5). |
| 77 | +- Range requests are forwarded but unadvertised; web-authoring and |
| 78 | + querystring bearer tokens (`rfc6750#section-2.3`) are not supported |
| 79 | + (declared `null` in the JRD properties). |
| 80 | + |
| 81 | +## Findings |
| 82 | + |
| 83 | +### 1. HEADLINE — the `/.well-known/webfinger` collision, now WITNESSED |
| 84 | + |
| 85 | +NOTES.md (seam 5) *predicted* that two plugins claiming the same |
| 86 | +`.well-known` route would collide; it had never been witnessed because no |
| 87 | +two co-loaded plugins wanted the same document. remoteStorage is the |
| 88 | +natural second claimant — its clients discover storage via a JRD link in |
| 89 | +exactly the file webfinger/ already serves. Both boots were measured: |
| 90 | + |
| 91 | +- **webfinger/ first, remotestorage/ second → the boot FAILS.** This |
| 92 | + plugin registers `GET /.well-known/webfinger` unguarded, and Fastify |
| 93 | + refuses the duplicate. The error, verbatim as the test asserts it: |
| 94 | + |
| 95 | + ``` |
| 96 | + plugin remotestorage: activate() failed: Method 'GET' already declared for route '/.well-known/webfinger' |
| 97 | + ``` |
| 98 | + |
| 99 | + Note the loader's wrap (`plugin <id>: activate() failed: …`) preserves |
| 100 | + Fastify's message but **drops `err.code`** (`FST_ERR_DUPLICATED_ROUTE`), |
| 101 | + so operators and tests must match on the message text. |
| 102 | +- **remotestorage/ first, webfinger/ second → the boot SUCCEEDS and |
| 103 | + webfinger/ silently loses.** webfinger/ wraps its claim in try/catch and |
| 104 | + degrades to prefix-only, so `/.well-known/webfinger` serves the |
| 105 | + remoteStorage JRD and every link webfinger/ would have contributed |
| 106 | + (profile-page, ActivityPub actor, OIDC issuer) is simply absent from the |
| 107 | + well-known document. No error, no warning a client ever sees. This is |
| 108 | + the *silent* form of the same collision NOTES.md predicted for links. |
| 109 | + |
| 110 | +So the outcome is **order- and guarding-dependent**: unguarded claims fail |
| 111 | +the boot loudly, guarded claims lose silently — both are wrong, because |
| 112 | +both plugins are *legitimate* owners of parts of one discovery document. A |
| 113 | +deployment today must choose ONE webfinger owner (`claimWellKnown: false` |
| 114 | +here, or drop webfinger/) and lose the other's links. The missing seam is a |
| 115 | +**link/JRD registry** — `api.webfinger.addLink(rel, buildLink)` or |
| 116 | +equivalent — where the loader owns the route once and plugins contribute |
| 117 | +links; that would dissolve both failure modes at once. |
| 118 | + |
| 119 | +### 2. Conditional-write pass-through: the host honors it (measured) |
| 120 | + |
| 121 | +`If-Match` and `If-None-Match` are forwarded verbatim over loopback and the |
| 122 | +host's LDP handlers implement them: a stale `If-Match` PUT → **412** from |
| 123 | +the host, `If-None-Match: *` on an existing document → **412**, |
| 124 | +`If-None-Match: *` on a new path → 201, a stale `If-Match` DELETE → |
| 125 | +**412**, and `If-None-Match: <etag>` GET → **304** — all asserted in |
| 126 | +test.js with the refused bodies proven not to have landed. No plugin-side |
| 127 | +conditional logic was needed for documents (folders are the exception, |
| 128 | +finding #3). This is the best case for the loopback pattern: the rS |
| 129 | +concurrency contract comes from the host for free, and it is atomic at the |
| 130 | +host (a plugin-side check-then-write would race). |
| 131 | + |
| 132 | +### 3. ETag provenance: host's for documents, hand-rolled for folders — and PUT/DELETE responses have none |
| 133 | + |
| 134 | +- **Documents: pass-through.** GET/HEAD relay the host's own ETag |
| 135 | + (md5 of mtime+size, computed by core storage). The test proves the ETag |
| 136 | + served at `<prefix>/alice/…` is byte-identical to the one LDP serves at |
| 137 | + `/alice/remotestorage/…`. |
| 138 | +- **But the host's PUT and DELETE responses carry NO ETag header** (LDP |
| 139 | + omits it), and rS requires one on both. The plugin therefore does a |
| 140 | + follow-up HEAD after PUT and a preceding HEAD before DELETE — one extra |
| 141 | + loopback round-trip per write, and not atomic: a concurrent write between |
| 142 | + PUT and HEAD reports the newer revision's ETag. A host that returned the |
| 143 | + new ETag on PUT (as its own bundled `src/remotestorage.js` does) would |
| 144 | + remove both the cost and the race. |
| 145 | +- **Folders: hand-rolled, for a measured reason.** The host's directory |
| 146 | + ETag is directory-mtime-based, and overwriting an existing child updates |
| 147 | + the *file's* mtime, not the directory's — so passing it through would |
| 148 | + leave folder listings looking unchanged after every document update, |
| 149 | + breaking rS sync. The plugin instead hashes the items map |
| 150 | + (`"rs-<sha256(name→ETag pairs)>"`); the test asserts the folder ETag |
| 151 | + changes when a child is overwritten. |
| 152 | + |
| 153 | +### 4. rS is a route-owning feature → plugin-able (7th port-shaped witness) |
| 154 | + |
| 155 | +The whole protocol fit the public api: one prefix for the data plane, the |
| 156 | +loopback pattern for storage and authorization, the mastodon/ token bridge |
| 157 | +for auth, `.well-known` by-luck for discovery. Nothing needed an internal — |
| 158 | +core's bundled `src/remotestorage.js` imports `storage/filesystem.js` and |
| 159 | +`auth/token.js` directly, and every one of those uses was replaced by a |
| 160 | +loopback HTTP call. That confirms #564's line again: remoteStorage OWNS its |
| 161 | +routes, so it moves out-of-tree — the 7th route-owning bundled feature this |
| 162 | +repo has ported. (Corollary: core currently serves `/storage/:user/*` |
| 163 | +always-on; an operator running this plugin has two rS implementations up. |
| 164 | +The migration list's point is that core's could be deleted.) |
| 165 | + |
| 166 | +### 5. Content types don't round-trip (measured) |
| 167 | + |
| 168 | +rS requires the stored `Content-Type` to be returned exactly. JSS stores |
| 169 | +only bytes and derives the type from the file *extension* on every GET: |
| 170 | +a document PUT as `text/csv` to an extension-less path comes back |
| 171 | +`application/octet-stream` (asserted in test.js). Documents whose names |
| 172 | +carry a truthful extension are fine; anything else loses its type. Fixing |
| 173 | +this needs per-resource metadata the plugin api doesn't offer (a `.meta` |
| 174 | +convention or host support) — same class of gap as backup/'s |
| 175 | +"permissions don't round-trip". |
| 176 | + |
| 177 | +### 6. Folder listings cost O(N) loopback HEADs, and depth ≥ 2 version propagation is unbuildable |
| 178 | + |
| 179 | +The LDP container listing carries `stat:size` and `dcterms:modified` per |
| 180 | +child but **no ETags**, so the plugin HEADs every child over loopback to |
| 181 | +get per-item ETags that exactly match a direct GET of that document (the |
| 182 | +property rS sync depends on — asserted in test.js). That is N+1 loopback |
| 183 | +requests per folder listing. Worse, rS wants a *folder's* version to change |
| 184 | +when **any descendant** changes; the items-hash folder ETag is correct for |
| 185 | +direct children, but a change two levels down doesn't alter the |
| 186 | +intermediate folder's host ETag (directory mtime), so it can't propagate |
| 187 | +without a full recursive walk per request. A faithful implementation needs |
| 188 | +either per-child ETags in the host's container listing (cheap host |
| 189 | +affordance) or a write-time version index — which is blocked on the |
| 190 | +**`api.events.onResourceChange`** seam, adding rS to notifications/, |
| 191 | +sparql/, search/, matrix/ and backup/ as its 6th consumer. |
| 192 | + |
| 193 | +### 7. Smaller notes |
| 194 | + |
| 195 | +- `api.auth.getAgent` ended up **unused**: forwarding the caller's |
| 196 | + `Authorization` over loopback subsumes every authorization decision the |
| 197 | + data plane needs, and the token endpoint takes a password, not a token. |
| 198 | + The token bridge's server side is just `/idp/credentials` — another |
| 199 | + witness that a Solid bearer and a protocol token are the same thing. |
| 200 | +- `<prefix>/token` and `<prefix>/webfinger` shadow pods named `token` / |
| 201 | + `webfinger` at the storage root — static routes beat the wildcard. |
| 202 | + Reserved names, documented here. |
| 203 | +- The webfinger JRD is served for **any well-formed local part** (no |
| 204 | + `podsRoot` scan): the storage URL behind it 401/404s for pods that don't |
| 205 | + exist. webfinger/'s filesystem scan is the richer behavior; duplicating |
| 206 | + it here would have doubled exactly the code the collision shows should be |
| 207 | + a shared registry. |
| 208 | +- The empty-folder case mirrors core's bundled rS: a folder that doesn't |
| 209 | + exist yet lists as `{}` with **no ETag**, so clients re-process it every |
| 210 | + cycle instead of getting a 304 that would skip their push logic. |
0 commit comments