Skip to content

Commit 067b647

Browse files
micropub plugin: IndieWeb Micropub server storing posts in the pod
Create/update/delete h-entries via loopback LDP with the caller's own bearer forwarded, so real WAC decides every write. Headline finding: the token bridge in its purest form — a pod bearer IS a Micropub token, no token endpoint needed at all (fourth witness after mastodon/bluesky/ap). Counter-witness for reservePath: client-discovered endpoints fit the one-prefix model exactly; media endpoint blocked on the raw-body-stream seam (#583); <link rel=micropub> discovery needs the response-header seam.
1 parent 9492062 commit 067b647

3 files changed

Lines changed: 668 additions & 0 deletions

File tree

micropub/README.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# micropub — an IndieWeb Micropub server over your own pod
2+
3+
A [Micropub](https://micropub.spec.indieweb.org/) endpoint as a #206 loader
4+
plugin: IndieWeb clients (Quill, Indigenous, Micropublish, plain curl) POST
5+
h-entries to one URL and the posts land as JSON resources **in the author's
6+
own pod**, at `<pod>/public/posts/<yyyy>/<mm>/<slug>.json`. The permalink
7+
returned in `Location:` *is* the pod resource URL, and every read/write goes
8+
over loopback with the client's own bearer, so real WAC governs everything.
9+
10+
```
11+
plugins: [{
12+
module: 'micropub/plugin.js',
13+
prefix: '/micropub',
14+
config: {
15+
baseUrl: 'http://localhost:3000', // public origin (permalinks + q=source guard)
16+
loopbackUrl: 'http://127.0.0.1:3000', // where the plugin reaches the host
17+
},
18+
}]
19+
```
20+
21+
No `appPaths` needed — unlike mastodon/ and bluesky/, the whole Micropub
22+
surface is one client-discovered URL, which fits the plugin's single
23+
WAC-exempt `prefix` exactly.
24+
25+
## A curl session
26+
27+
Micropub says clients send `Authorization: Bearer <token>`. On JSS, **a pod
28+
bearer IS a Micropub token** — mint one from the IdP and use it directly:
29+
30+
```bash
31+
# 1. a pod token (this IS your micropub token — see Findings)
32+
TOKEN=$(curl -s localhost:3000/idp/credentials \
33+
-H 'content-type: application/json' \
34+
-d '{"username":"alice","password":"…"}' | jq -r .access_token)
35+
36+
# 2. what does the endpoint support?
37+
curl -s 'localhost:3000/micropub?q=config'
38+
# → {"syndicate-to":[]}
39+
40+
# 3. post a note (form-encoded, like every Micropub client's simplest path)
41+
curl -si localhost:3000/micropub -H "Authorization: Bearer $TOKEN" \
42+
-d h=entry -d 'content=hello indieweb' \
43+
-d 'category[]=indieweb' -d 'category[]=solid' | grep -i location
44+
# → Location: http://localhost:3000/alice/public/posts/2026/07/1760…-a1b2c3d4.json
45+
46+
# 4. it's a plain pod resource — read it back like any other
47+
curl -s "$LOCATION" -H "Authorization: Bearer $TOKEN"
48+
# → {"type":["h-entry"],"properties":{"content":["hello indieweb"],…}}
49+
50+
# 5. or ask the endpoint for the source
51+
curl -s "localhost:3000/micropub?q=source&url=$LOCATION" -H "Authorization: Bearer $TOKEN"
52+
53+
# 6. edit and delete
54+
curl -s localhost:3000/micropub -H "Authorization: Bearer $TOKEN" \
55+
-H 'content-type: application/json' \
56+
-d '{"action":"update","url":"'$LOCATION'","replace":{"content":["edited"]}}'
57+
curl -s localhost:3000/micropub -H "Authorization: Bearer $TOKEN" \
58+
-d action=delete -d "url=$LOCATION"
59+
```
60+
61+
## What maps / what doesn't
62+
63+
| Micropub feature | Status | Notes |
64+
|---|---|---|
65+
| `POST` create, form-encoded (`h=entry`, `category[]=…`) | ✅ done | arrays preserved; `mp-slug` honored |
66+
| `POST` create, JSON (`{"type":["h-entry"],"properties":{…}}`) | ✅ done | properties stored verbatim, values normalized to arrays |
67+
| `201` + `Location:` permalink | ✅ done | the permalink is the pod resource URL |
68+
| `GET ?q=config` / `?q=syndicate-to` | ✅ done | `{"syndicate-to":[]}`, no `media-endpoint` |
69+
| `GET ?q=source&url=…` (+ `properties[]` filter) | ✅ done | only for URLs under this server — no external fetching |
70+
| `action=delete` | ✅ done | loopback DELETE → 204 |
71+
| `action=update` with `replace` | ✅ done | GET-merge-PUT → 204 |
72+
| `action=update` with `add` / `delete` ops | ⛔ not implemented | replace covers the editing slice; same GET-merge-PUT shape if wanted |
73+
| post types other than h-entry | ⛔ 400 | h-entry only |
74+
| **media endpoint** (file upload) | ⛔ not implemented | needs multipart/streaming bodies — see Findings; `photo` as a URL value works |
75+
| syndication, webmention sending | ⛔ by design | this plugin makes **no** external network calls |
76+
| IndieAuth server / endpoint discovery | ⛔ out of scope | any pod bearer works (see Findings on discovery) |
77+
78+
Storage shape: the Micropub JSON is stored as-is —
79+
`{"type":["h-entry"],"properties":{…}}` with `published` added when absent —
80+
so `q=source` is a passthrough read and any other pod tool sees honest data.
81+
Slugs come from `mp-slug`, then the post `name`, then
82+
timestamp+random (`node:crypto`); an existing resource at the same slug gets
83+
a random suffix rather than being overwritten (checked with a loopback HEAD).
84+
85+
## Findings
86+
87+
### 1. The token bridge, fourth witness — and the purest one
88+
89+
mastodon/ and bluesky/ established that an OAuth/ATProto access token and a
90+
Solid pod bearer are the same kind of thing (a value resent in
91+
`Authorization`), and bridged them with a `/oauth/token`-style endpoint
92+
calling `/idp/credentials` over loopback. Micropub is the next consumer of
93+
that insight, and the degenerate case that proves it: **no token endpoint is
94+
needed at all**. Micropub's spec just says "Bearer token in Authorization",
95+
so a pod bearer is presented directly, `api.auth.getAgent(request)` resolves
96+
it, and every pod operation forwards it over loopback for real WAC to judge.
97+
The bridge collapsed to the identity function. (Where a client insists on a
98+
real IndieAuth flow, the mastodon/ `/oauth/*` pattern shows exactly how a
99+
token endpoint would wrap `/idp/credentials`.)
100+
101+
### 2. Media endpoint blocked on the streaming-body primitive (#583)
102+
103+
Micropub's media endpoint takes `multipart/form-data` file uploads. JSS's
104+
wildcard parser hands a plugin the body as a **buffered** Buffer, which is
105+
the wrong primitive twice over: multipart parsing wants the raw stream (or a
106+
parser hook), and a photo upload should be *piped* to the pod, not held in
107+
memory. This is the same unconsumed-body-**stream** seam gitscratch/ and
108+
tunnel/ sharpened (NOTES.md #4, issue #583): whatever raw-body mode ships
109+
must hand back the un-drained stream. Until then, `photo` is supported as a
110+
URL value only and `q=config` deliberately advertises no `media-endpoint`.
111+
Micropub media is a new, very concrete consumer for that seam.
112+
113+
### 3. Endpoint discovery needs headers on routes the plugin doesn't own
114+
115+
Micropub clients find the endpoint by fetching the user's homepage and
116+
looking for `<link rel="micropub" href="…">` (or a `Link:` header). The
117+
user's homepage on JSS is a pod resource — owned by the LDP core, not this
118+
plugin — and a plugin cannot inject a header or a link into responses for
119+
routes it doesn't own. That's the response-header-injection seam NOTES.md #8
120+
records with notifications/ (`Updates-Via`) as its first consumer; Micropub
121+
discovery is the second, with the same shape (a *discovery* header on core
122+
responses, the mildest form of a pipeline hook). Workarounds today: the pod
123+
owner adds the `<link>` to their own homepage HTML by hand, or the operator
124+
documents the endpoint out of band. Both work — the test suite simply talks
125+
to `/micropub` directly — but a `rel=micropub` on the profile is what real
126+
clients expect.
127+
128+
### 4. Counter-witness: the one-prefix model fits Micropub exactly
129+
130+
Worth recording because it sharpens the reservePath finding by contrast:
131+
mastodon/ (2 fixed roots), bluesky/ (1), and activitypub/ (interleaved)
132+
all fought the single-prefix model, but Micropub — whose endpoint URL is
133+
*client-discovered*, not protocol-fixed — fits it perfectly. No `appPaths`,
134+
no `.well-known` luck, no operator hand-widening; the protocol's own
135+
discovery indirection (finding 3) is what buys that freedom. The seam story
136+
is therefore about protocols with **fixed absolute paths**, not API shims
137+
per se.
138+
139+
### 5. Small mercies that just worked
140+
141+
- **Deep PUT creates intermediate containers**: `PUT
142+
/alice/public/posts/2026/07/x.json` materializes `posts/2026/07/` without
143+
any container dance — the date-sharded layout cost nothing.
144+
- **`config.baseUrl`/`loopbackUrl` again** (the `api.serverInfo` finding,
145+
Nth consumer): needed for permalinks and the q=source "is this URL ours?"
146+
guard, forcing the probe-port-then-boot test dance like ~10 siblings.
147+
- **No existence-check primitive** — slug-collision avoidance is a loopback
148+
`HEAD` with the caller's own bearer; the loopback pattern absorbs yet
149+
another would-be api surface.

0 commit comments

Comments
 (0)