Skip to content

Commit 712c5e9

Browse files
remotestorage plugin: draft-dejong-remotestorage server over pods — 7th port
GET/HEAD/PUT/DELETE <prefix>/<user>/<category>/<path> maps onto pod resources over loopback with the caller's auth forwarded; rS folder listings with per-item host ETags; mastodon-style token bridge. Core ships a bundled always-on rS (src/remotestorage.js), so this is the port proving it could move out-of-tree — the 7th #564 witness. Headline: the .well-known/webfinger collision, WITNESSED both ways. webfinger/ first + remotestorage/ second → boot fails ('Method GET already declared for route /.well-known/webfinger'; the loader's wrap drops err.code, so only the message identifies FST_ERR_DUPLICATED_ ROUTE). Reverse order → boot succeeds and webfinger/'s try/catch- guarded claim silently LOSES — its profile/actor/issuer links vanish with no error. Unguarded claims fail loudly, guarded claims lose silently; both are wrong — the missing seam is a link/JRD registry (api.webfinger.addLink). claimWellKnown: false stands down for composed deployments. Also measured: the host honors forwarded If-Match/If-None-Match end-to-end (412s, atomic); content types don't round-trip (extension-derived); PUT/DELETE responses carry no ETag (extra HEAD round-trip).
1 parent a469ef7 commit 712c5e9

3 files changed

Lines changed: 1069 additions & 0 deletions

File tree

remotestorage/README.md

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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

Comments
 (0)