Skip to content

Commit 240c3d4

Browse files
Integrate wave 10 (sparql UPDATE, caldav free-busy, shortlink): 32 plugins, 363 tests
compose.test.js and serve.js boot all 32 (shortlink at /short, probed in compose and on the dashboard). Docs folded: api.authorize gains its 4th consumer (caldav scheduling — Outbox→Inbox delivery is a write the sender has no WAC right to make, loopback structurally can't express it); the api.events entry gains two sharpenings (owning a write endpoint doesn't buy a write-time index — plain-LDP writes bypass it; a shared index must answer 'indexed under whose authority?'); the conditional-write 'what worked' entry gains its measured caveat and a 5th filed-worthy bug — the host's If-Match check is check-then-write, not atomic: two concurrent writers both told 2xx, one silently lost. maxParamLength gains its 2nd consumer; new smaller note on pluginDir-vs-pod state placement (no doctrine; 11 pluginDir witnesses). REPORT.md updated throughout.
1 parent 59696f0 commit 240c3d4

8 files changed

Lines changed: 104 additions & 43 deletions

File tree

AGENT.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Out-of-tree plugins for [JavaScript Solid
1111
Server](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer),
1212
built on its **#206 loader** (`createServer({ plugins })`, JSS ≥ 0.0.215).
1313
It is an *experiment*: prove the plugin api by using it, and treat every
14-
wall you hit as a finding, not a blocker. **31 plugins, 332 tests today.**
14+
wall you hit as a finding, not a blocker. **32 plugins, 363 tests today.**
1515

1616
### The one rule that makes the experiment valid
1717

@@ -156,6 +156,7 @@ Full ranking in `NOTES.md`. The ones you'll hit:
156156
| a per-resource metadata/unfurl endpoint | `oembed/` | loopback resolve + local-URL-only guard |
157157
| a stateless request/response protocol (mail, sync, …) | `jmap/` | loopback CRUD + in-protocol refusal of push/delta |
158158
| a storage protocol with conditional writes | `remotestorage/` | If-Match/If-None-Match pass through loopback intact |
159+
| a redirect / metadata micro-service | `shortlink/` | pluginDir JSON table + wildcard slug routes |
159160
160161
## Footguns (every multi-boot suite rediscovered these)
161162
@@ -177,7 +178,7 @@ Full ranking in `NOTES.md`. The ones you'll hit:
177178
178179
## Current state
179180
180-
31 plugins (7 ports + 24 features), 332 tests, all green (`npm test`).
181+
32 plugins (7 ports + 25 features), 363 tests, all green (`npm test`).
181182
`compose.test.js` runs every one on a single server from pure config. Two
182183
core PRs (#590 `api.mountApp`, #591 `/idp/refresh`) sit upstream, unmerged,
183184
for the maintainer's call. Everything else lives here, by design.

ISSUES.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ evidence. Legend:
2121
| #382 / #379 | `corsproxy/` | CORS forward proxy, fail-closed SSRF defense |
2222
| #506 | `capability/` | scoped, time-bound, revocable capability URLs |
2323
| #507 | `webdav/` | mount a pod in Finder/Nautilus/Windows |
24-
| #509 | `sparql/` | read-time SPARQL over pod JSON-LD (write-index → 🔩 `api.events`) |
24+
| #509 | `sparql/` | SPARQL SELECT + UPDATE over pod JSON-LD (write-index → 🔩 `api.events`) |
2525
| #322 | `gitscratch/` | ephemeral Solid-authed git remotes (git-http-backend CGI) |
2626
| #515 / #516 | `mastodon/` | Mastodon-API shim — a client can log into its own pod (needs `appPaths` widened → 🔩) |
2727
| #505 | `otp/` | one-time-password session flow (account *recovery* → 🔩 core auth) |
@@ -42,6 +42,7 @@ evidence. Legend:
4242
|| `oembed/` | oEmbed provider (discovery injection → 🔩 header/content hooks) |
4343
|| `jmap/` | JMAP mail over the pod (push/delta → 🔩 `api.events`; blobs → 🔩 #583) |
4444
| #163 | `remotestorage/` | remoteStorage server — 7th port; witnessed the webfinger collision |
45+
|| `shortlink/` | link shortener for pod URLs (pluginDir persistence, 11th witness) |
4546

4647
Plus seven **ports of bundled features** proving the migration path for
4748
#564 / #164: `relay/` `webrtc/` `terminal/` `tunnel/` `notifications/`
@@ -105,11 +106,11 @@ exists.
105106

106107
Of ~40 plugin-tagged issues: **14 built as plugins here** (plus `rss/`,
107108
`matrix/`, `search/`, `didweb/`, `s3/`, `micropub/`, `backup/`, `metrics/`,
108-
`dashboard/`, `oembed/`, `jmap/` — capability
109+
`dashboard/`, `oembed/`, `jmap/`, `shortlink/` — capability
109110
demonstrations with no single issue), **7 ported**, **5 shipped upstream**,
110111
**2 more plugin-able with no blocker**, **6 clusters blocked on a named
111112
seam** (each with a proof-of-need consumer), the rest core-by-nature or
112-
product-scale. **31 plugins total, 332 tests.** The plugin api reaches most
113+
product-scale. **32 plugins total, 363 tests.** The plugin api reaches most
113114
of the backlog today; ranked by demand, the seams that would unlock the
114115
most next are `api.authorize` (3 blocking consumers), `api.events` (7
115116
consumers — matrix `/sync` needs live push, jmap can't do push or delta

NEXT.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ plugin), `NOTES.md` (the findings/seams — the real deliverable), and
88

99
## Where things stand
1010

11-
- **31 plugins, 332 tests, all green** (`npm test`), all pushed to
11+
- **32 plugins, 363 tests, all green** (`npm test`), all pushed to
1212
`github.com/JavaScriptSolidServer/plugins` (branch `gh-pages`).
13-
- `compose.test.js` boots all 31 on **one** JSS from pure config; `serve.js`
13+
- `compose.test.js` boots all 32 on **one** JSS from pure config; `serve.js`
1414
is the runnable demo. Both must be updated when you add a plugin.
1515
- Built so far: 7 ports (relay, webrtc, terminal, tunnel, notifications,
16-
remotestorage, pay) + 24 features (nip05, corsproxy, capability, webdav,
17-
sparql, gitscratch, otp, carddav, mastodon, bluesky, caldav, webfinger,
16+
remotestorage, pay) + 25 features (nip05, corsproxy, capability, webdav,
17+
sparql — now with UPDATE, gitscratch, otp, carddav, mastodon, bluesky,
18+
caldav — now with free-busy, webfinger,
1819
activitypub, rss, matrix, search, didweb, s3, micropub, backup,
19-
metrics, dashboard, oembed, jmap).
20+
metrics, dashboard, oembed, jmap, shortlink).
2021
- Capability classes covered: realtime, WebDAV family, fediverse/social/chat
2122
(5 shims), IndieWeb publishing, identity, data/query/search, object
2223
storage, proxy, dev, pay, data portability, ops/observability, mail,
@@ -76,10 +77,10 @@ Still genuinely plugin-shaped and distinct:
7677
- **feed ingest** — subscribe to external RSS/Atom, store items in the pod
7778
(the inverse of `rss/`; uses a corsproxy-style fetch — outbound fetches:
7879
same careful-session caveat as webhooks/webmention).
79-
- **link-shortener**, **WebSub/PubSubHubbub** (needs `api.events` +
80-
outbound POSTs — deferred),
81-
**SPARQL UPDATE** (extends `sparql/`), **CalDAV scheduling/free-busy**
82-
(extends `caldav/`).
80+
- **WebSub/PubSubHubbub** (needs `api.events` + outbound POSTs — deferred).
81+
- **CalDAV scheduling (RFC 6638)** — blocked on cross-user delivery
82+
(api.authorize's issuer-authority case, or a deliver-to-inbox
83+
primitive); free-busy is done.
8384
- **Bluesky/Mastodon/Matrix Phase-2** (federation, `/sync` live push) —
8485
these are blocked on `api.events` + `api.reservePath`; good once those
8586
seams exist, otherwise document the wall.
@@ -94,12 +95,14 @@ it sharpens the case.
9495
`NOTES.md` ranks candidate seams by how many independent plugins demanded
9596
each. Current top four (keep this current as you add consumers):
9697

97-
1. **`api.authorize(request, path, mode)`** — 3 consumers; the top
98+
1. **`api.authorize(request, path, mode)`** — 4 consumers (caldav
99+
scheduling joined); the top
98100
*blocking* seam (authority the requester doesn't drive).
99-
2. **`api.events.onResourceChange`** — 5 consumers (backup/ made it 5:
100-
incremental/scheduled backup is unbuildable); matrix `/sync` needs
101-
live push. Every "react to writes" plugin (webhooks, WebSub, indexing)
102-
will want it — webhooks/ would make it 6.
101+
2. **`api.events.onResourceChange`** — 7 consumers (backup, jmap,
102+
remotestorage joined); matrix `/sync` needs
103+
live push, and sparql/'s UPDATE proved owning a write endpoint does
104+
not buy a write-time index. Every "react to writes" plugin (webhooks,
105+
WebSub, indexing) will want it.
103106
3. **`api.reservePath`** — every API-shim owns fixed roots outside its one
104107
prefix and can't self-exempt; didweb needs a *parameterized* form.
105108
(micropub/ is the counter-witness: client-discovered endpoints need no

NOTES.md

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,31 @@ are candidates, each with a consumer in this repo attached.
2424
correct. This removes a whole class of would-be seams (`api.wac.check`)
2525
from the *necessary* list, leaving them merely *nice*.
2626
- **Conditional writes pass through loopback intact** (remotestorage/,
27-
measured): `If-Match`/`If-None-Match` forwarded verbatim are honored by
27+
measured; sparql/ is the second consumer): `If-Match`/`If-None-Match`
28+
forwarded verbatim are honored by
2829
the host end-to-end — stale `If-Match` PUT/DELETE → 412,
2930
`If-None-Match: *` on an existing resource → 412 with the body proven
30-
not to land, `If-None-Match` GET → 304. Atomic at the host; no
31-
plugin-side conditional logic needed.
31+
not to land, `If-None-Match` GET → 304. No plugin-side conditional
32+
logic needed for the stale-writer case. **Caveat measured by sparql/:**
33+
the host's check is check-then-write, not atomic — see the bugs section.
3234

3335
## Candidate seams (ranked by how many independent plugins demanded them)
3436

3537
Twelve plugins in, the ranking is now empirical — a seam's rank is how many
3638
ports reached for it without coordinating.
3739

3840
1. **`api.authorize(request, path, mode)`** — "would the host's WAC allow
39-
this?" **Three independent consumers: notifications/, corsproxy/,
40-
capability/.** The loopback trick (below) covers the case where the
41-
*requester's own* credentials should decide (notifications, webdav,
41+
this?" **Four independent consumers: notifications/, corsproxy/,
42+
capability/, caldav/.** The loopback trick (below) covers the case where
43+
the *requester's own* credentials should decide (notifications, webdav,
4244
sparql all use it), but it can't cover authorization the requester
4345
doesn't drive: a proxy governed by a *pod owner's* `.acl` (corsproxy
44-
#382), or a capability exercising the *issuer's* authority
45-
(capability #506). This is the most-requested seam and the one that
46+
#382), a capability exercising the *issuer's* authority
47+
(capability #506), or CalDAV scheduling (RFC 6638), where Outbox→Inbox
48+
delivery is a write into the *recipient's* pod the sender has no WAC
49+
right to make — loopback structurally cannot express it (a
50+
server-mediated deliver-to-inbox primitive is the narrower
51+
alternative). This is the most-requested seam and the one that
4652
moves the most backlog issues from "plugin-approximation" to "faithful".
4753
2. **`api.events.onResourceChange(cb)`****seven consumers now:
4854
notifications/, sparql/, search/, matrix/, backup/, jmap/,
@@ -60,6 +66,13 @@ ports reached for it without coordinating.
6066
(`eventSourceUrl`) and delta sync (`*/changes`, `Email/queryChanges`)
6167
are omitted/refused in-protocol), and remotestorage (rS's
6268
descendant-version propagation at depth ≥ 2 needs a write-time index).
69+
sparql/'s UPDATE work sharpened the whole entry: **owning a write
70+
endpoint does not buy the write-time index** — a plugin could index
71+
its own writes synchronously, but plain-LDP writes bypass it, yielding
72+
a silently-wrong index (worse than none); only the host's event stream
73+
sees all writes. caldav/'s free-busy added a wrinkle: a shared
74+
write-time index must answer "indexed under whose authority?" or it
75+
leaks WAC-hidden data.
6376
Core already has the
6477
emitter internally (`src/notifications/events.js`); this is the seam
6578
every "react to pod writes" app (webhooks, indexing, sync, search, live
@@ -132,9 +145,10 @@ ports reached for it without coordinating.
132145
endpoints are client-discovered rather than protocol-fixed — the seam
133146
is specifically about protocols that pin absolute paths, not API shims
134147
per se.
135-
6. **Can't set fastify server options**consumer: capability/ hit
148+
6. **Can't set fastify server options**consumers: capability/ hit
136149
`maxParamLength` (100) silently 404ing long tokens in named params;
137-
workaround is a wildcard route. A plugin has no way to raise per-route
150+
shortlink/ dodged it pre-emptively the same way. Workaround is a
151+
wildcard route. A plugin has no way to raise per-route
138152
limits. Minor, but sharp when it bites.
139153
7. **Internal utility modules plugins re-vendor** — consumer: relay/
140154
(`src/nostr/event.js` NIP-01 verify), pay/ (`src/mrc20.js`). Pure crypto.
@@ -233,6 +247,12 @@ this repo is its proof.
233247
candidate: when the basename is generic (`plugin`, `index`), derive from
234248
the parent directory (`relay/plugin.js``relay`). Small, backward-
235249
compatible, removes the most common footgun. **Filed-worthy.**
250+
- **The host's conditional write is check-then-write, not atomic**
251+
(sparql/, measured live): two *concurrent* PUTs carrying the same
252+
currently-valid `If-Match` both return 2xx and the loser is silently
253+
overwritten; the same two PUTs run sequentially 412 correctly. Two
254+
simultaneous writers can lose one with both told success — unfixable
255+
plugin-side; the host needs an atomic stat+write. **Filed-worthy.**
236256
- **The loader's activate-failure wrap drops `err.code`**
237257
(remotestorage/): a route collision inside `activate` surfaces as
238258
`plugin <id>: activate() failed: <message>` with the original
@@ -280,3 +300,10 @@ this repo is its proof.
280300
exported (loopback enforces Control for free) but absolute-URL ACLs
281301
don't restore elsewhere — policy portability is a spec-level gap, not a
282302
plugin-api gap.
303+
- **pluginDir vs pod resources — no doctrine** (shortlink/): stateful
304+
plugins choose between `pluginDir` (fast, private, invisible to WAC,
305+
backup, and portability) and pod resources (WAC-governed, portable,
306+
but a loopback round-trip per operation). The api offers one primitive
307+
and no guidance; a design note in the plugin docs would spare every
308+
author the same deliberation. Eleven plugins now persist in
309+
`pluginDir` — the most settled seam in the api.

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ README and NOTES.md, ship the closest honest approximation.
6969
| `corsproxy/` | #382/#379 | forward proxy with fail-closed SSRF defense; per-pod ACL needs `api.authorize` |
7070
| `capability/` | #506 | macaroon-lite scoped/time-bound/revocable capability URLs |
7171
| `webdav/` | #507 | mount a pod in Finder/Nautilus/Windows; WebDAV↔LDP over loopback, Basic→Bearer |
72-
| `sparql/` | #509 | read-time SPARQL SELECT over pod JSON-LD; write-index needs `api.events` |
72+
| `sparql/` | #509 | SPARQL SELECT + UPDATE over pod JSON-LD; write-index still needs `api.events` |
7373
| `gitscratch/` | #322 | ephemeral Solid-authed git remotes via the `git-http-backend` CGI |
7474
| `otp/` | #505 | one-time-password session flow; account recovery needs core auth |
7575
| `carddav/` | #157 | contact sync (iOS/macOS/Thunderbird/DAVx5); the DAV bridge, generalized |
7676
| `mastodon/` | #515/#516 | Mastodon-API shim — a client logs into its own pod; needs `appPaths` widened |
7777
| `bluesky/` | #211 | AT-Protocol XRPC shim — same shape, `/xrpc` root; token bridge generalizes |
78-
| `caldav/` | #157-sib | calendar sync — completes the DAV family (webdav+carddav+caldav) |
78+
| `caldav/` | #157-sib | calendar sync + free-busy — completes the DAV family (webdav+carddav+caldav) |
7979
| `webfinger/` | #164 | `/.well-known/webfinger` — fediverse `acct:` resolution for the shims |
8080
| `activitypub/` | #51/#164 | federate a pod as an AS2 actor; outbound HTTP Signatures |
8181
| `rss/` || any pod container as an Atom/RSS feed |
@@ -89,9 +89,10 @@ README and NOTES.md, ship the closest honest approximation.
8989
| `dashboard/` || live plugin-status page; first consumer of the #463/#464 registry seam |
9090
| `oembed/` || oEmbed provider for pod resources (link unfurling; XSS-refusing) |
9191
| `jmap/` || JMAP mail over pod messages — the stateless/push line, sharpened |
92+
| `shortlink/` || link shortener for pod URLs (deliberately not an open redirector) |
9293

93-
**332 tests, all green** (`npm test`), including `compose.test.js` — all
94-
thirty-one plugins on one server from pure config, pods + WAC intact
94+
**363 tests, all green** (`npm test`), including `compose.test.js` — all
95+
thirty-two plugins on one server from pure config, pods + WAC intact
9596
beside them. Findings consolidated in [NOTES.md](./NOTES.md); the full
9697
plugin-tagged backlog triaged in [ISSUES.md](./ISSUES.md); the build guide
9798
is [AGENT.md](./AGENT.md); the maintainer-facing summary — what to add

REPORT.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# The plugin api, 31 plugins later — a report for the maintainer
1+
# The plugin api, 32 plugins later — a report for the maintainer
22

33
This document is the actionable summary of the whole experiment: what the
44
#206 plugin api can already do, what it can't, and — ranked with evidence —
@@ -14,7 +14,7 @@ want it.
1414
## Executive summary
1515

1616
- The api as shipped in 0.0.215 (`createServer({ plugins })` + `prefix` +
17-
`getAgent` + `pluginDir` + `ws.route`) is **sufficient for 31 real
17+
`getAgent` + `pluginDir` + `ws.route`) is **sufficient for 32 real
1818
plugins across fifteen capability classes** — realtime, DAV,
1919
fediverse/chat shims, IndieWeb publishing, identity, query/search,
2020
object storage, proxy, dev tooling, pay, data portability,
@@ -30,10 +30,11 @@ want it.
3030
`api.events.onResourceChange`, `api.reservePath`, and `api.serverInfo`.
3131
Adding the first three moves essentially every remaining plugin-shaped
3232
issue from "honest approximation" to "faithful implementation".
33-
- Four small loader/core bugs are worth fixing regardless
33+
- Five small loader/core bugs are worth fixing regardless
3434
(generic-basename id derivation; dotted-prefix WS validation;
3535
`logger: false` breaking every plugin `onResponse` hook; the loader's
36-
error wrap dropping `err.code`).
36+
error wrap dropping `err.code`; the host's conditional write being
37+
check-then-write rather than atomic).
3738
- One measured surprise: **plugins are not isolated from each other**
3839
all entries share one Fastify register scope, so any plugin can hook
3940
every other plugin's routes (never core's). Worth a deliberate
@@ -62,9 +63,11 @@ Validated by use, not opinion:
6263
*not* on the ask list — only the cases loopback structurally can't
6364
cover are.
6465
6. **Conditional writes survive loopback intact** (measured by
65-
`remotestorage/`): forwarded `If-Match`/`If-None-Match` are honored by
66-
the host end-to-end (412 on stale, 304 on match, atomic). A protocol
67-
that needs optimistic concurrency gets it for free.
66+
`remotestorage/`; `sparql/`'s UPDATE is the second consumer):
67+
forwarded `If-Match`/`If-None-Match` are honored by the host
68+
end-to-end (412 on stale, 304 on match). A protocol that needs
69+
optimistic concurrency gets the stale-writer case for free — but see
70+
bug 5: under true concurrency the check is not atomic.
6871

6972
## The seams to add, ranked by independent demand
7073

@@ -77,7 +80,8 @@ entry is written as a fileable issue: motivation, consumers, sketch, cost.
7780
*requester's credentials don't drive*.
7881

7982
**Why loopback can't cover it:** loopback answers "may *this caller* read
80-
X?". Three plugins need "may *someone else* — a pod owner, a token issuer —
83+
X?". Four plugins need "may *someone else* — a pod owner, a token issuer,
84+
a message recipient —
8185
authorize this?":
8286

8387
- `corsproxy/` (#382): per-pod proxy ACLs — the *pod owner's* `.acl`
@@ -87,6 +91,10 @@ authorize this?":
8791
can only enforce its own token scopes, not re-check the issuer's current
8892
WAC rights (revocation by ACL edit doesn't propagate).
8993
- `pay/`: the 402 gate wants to compose with WAC instead of replacing it.
94+
- `caldav/` (RFC 6638 scheduling): Outbox→Inbox delivery is a write into
95+
the *recipient's* pod the sender has no WAC right to make — loopback
96+
structurally cannot express it. (A narrower server-mediated
97+
deliver-to-inbox primitive would also cover this one.)
9098

9199
**Sketch:** `api.authorize({ agent, path, mode }) → Promise<boolean>`
92100
agent id (not request) in, decision out, same engine the LDP path uses.
@@ -197,7 +205,7 @@ probe-port-then-boot dance for the same reason.
197205
Deliberately *not* asked for as a default-on hook: it's a bigger grant
198206
than route ownership. If ever, gate it: `capabilities: ['hooks']`.
199207

200-
## Four loader/core bugs worth fixing regardless
208+
## Five loader/core bugs worth fixing regardless
201209

202210
1. **Generic-basename id derivation.** Every plugin follows
203211
`<name>/plugin.js`, so every derived id is `plugin` and the (correct)
@@ -219,6 +227,12 @@ probe-port-then-boot dance for the same reason.
219227
failed: <message>` with `FST_ERR_DUPLICATED_ROUTE` stripped — callers
220228
can only string-match. Preserve `code` (or use `cause`) when
221229
re-wrapping (found by `remotestorage/`).
230+
5. **The conditional write is check-then-write, not atomic** (found live
231+
by `sparql/`): two *concurrent* PUTs carrying the same currently-valid
232+
`If-Match` both return 2xx and one is silently overwritten; the same
233+
PUTs run sequentially 412 correctly. Both callers are told success but
234+
one write is gone. Unfixable from a plugin; the LDP write path needs
235+
an atomic stat+write.
222236

223237
## A measured surprise: plugins are not isolated from each other
224238

0 commit comments

Comments
 (0)