Skip to content

Commit 1bc41ed

Browse files
matrix plugin: Matrix Client-Server API shim (chat protocol)
Login (m.login.password -> pod bearer via /idp/credentials bridge) -> whoami -> createRoom (room JSON in the pod) -> send m.room.message (timeline append, txnId idempotent) -> GET messages -> joined_rooms. /sync stubbed full-state. user_id=@<pod>:<host>, writes are loopback LDP PUTs under the caller's bearer (real WAC). Fixed /_matrix root. Findings: (1) reserved-path/appPaths Nth confirmation, now a CHAT protocol — the shim seam holds social->microblog->chat. (2) token bridge generalizes to a 4th protocol. (3) sharpest api.events case yet: timelines fit (read-time N+1), but Matrix /sync?since= long-poll needs server-side sync cursors + LIVE PUSH on writes = api.events.onResourceChange driving api.ws.route. A stateless bridge can only do full-state /sync. 12/12.
1 parent 4e1ad76 commit 1bc41ed

3 files changed

Lines changed: 828 additions & 0 deletions

File tree

matrix/README.md

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# matrix — a Matrix Client-Server API shim over your own pod
2+
3+
Point a Matrix client (Element, or any raw Client-Server API caller) at a
4+
JavaScript Solid Server and log in with your pod credentials, create a room,
5+
send a message, and read the timeline back. This is **Phase 1** — "a personal
6+
Matrix client over your own pod." No federation, no other homeservers, no
7+
incremental `/sync`: a **room is a JSON resource in your own pod** and its
8+
timeline is the events you appended to it.
9+
10+
This is the **chat-protocol** entry in the API-shim family — after the two
11+
microblog shims (`mastodon/`, `bluesky/`) it confirms the same two seams a
12+
third time, now against a fixed `/_matrix` root: the reserved-path/`appPaths`
13+
gap and the `/idp/credentials` token bridge.
14+
15+
```
16+
plugins: [{
17+
module: 'matrix/plugin.js',
18+
config: {
19+
baseUrl: 'http://localhost:3000', // public origin (server_name + loopback)
20+
loopbackUrl: 'http://127.0.0.1:3000', // optional: where the shim reaches the host
21+
},
22+
}]
23+
```
24+
25+
**and** — the load-bearing caveat — the operator must WAC-exempt the fixed
26+
Matrix root, because a plugin cannot do it itself (see [Findings](#findings)):
27+
28+
```
29+
createServer({
30+
appPaths: ['/_matrix'], // REQUIRED: or WAC 401s every client call
31+
plugins: [ … ],
32+
})
33+
```
34+
35+
## Pointing a client at it
36+
37+
A Matrix client asks for a **homeserver base URL** and then logs in. Give it
38+
your JSS origin (`http://localhost:3000`). It will `GET /_matrix/client/versions`,
39+
then `POST /_matrix/client/v3/login` with your username + password; the bearer
40+
it gets back **is** your pod token, resent on every later call.
41+
42+
```bash
43+
# 1. supported versions (public — the client's first probe)
44+
curl -s localhost:3000/_matrix/client/versions
45+
46+
# 2. log in (password → pod bearer)
47+
TOKEN=$(curl -s localhost:3000/_matrix/client/v3/login \
48+
-H 'content-type: application/json' \
49+
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"alice"},"password":"…"}' \
50+
| jq -r .access_token)
51+
52+
# 3. who am I
53+
curl -s localhost:3000/_matrix/client/v3/account/whoami -H "Authorization: Bearer $TOKEN"
54+
55+
# 4. create a room
56+
ROOM=$(curl -s localhost:3000/_matrix/client/v3/createRoom \
57+
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
58+
-d '{"name":"Test Room"}' | jq -r .room_id)
59+
60+
# 5. send a message (txnId is the client's idempotency key)
61+
curl -s -X PUT "localhost:3000/_matrix/client/v3/rooms/$ROOM/send/m.room.message/txn1" \
62+
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
63+
-d '{"msgtype":"m.text","body":"hello matrix"}'
64+
65+
# 6. read the timeline back
66+
curl -s "localhost:3000/_matrix/client/v3/rooms/$ROOM/messages" -H "Authorization: Bearer $TOKEN"
67+
```
68+
69+
## Endpoint coverage
70+
71+
| Endpoint | Status | Notes |
72+
|---|---|---|
73+
| `GET /_matrix/client/versions` | ✅ done | public; supported CS-API versions |
74+
| `GET /.well-known/matrix/client` | ✅ done | homeserver autodiscovery (`base_url`) |
75+
| `GET /_matrix/client/v3/login` | ✅ done | advertises the `m.login.password` flow |
76+
| `POST /_matrix/client/v3/login` | ✅ done | `m.login.password` → pod bearer as `access_token` |
77+
| `GET /_matrix/client/v3/account/whoami` | ✅ done | `user_id` from `getAgent()` |
78+
| `POST /_matrix/client/v3/createRoom` | ✅ done | room state → `<pod>/matrix/rooms/<localpart>.json` |
79+
| `PUT /_matrix/client/v3/rooms/{id}/send/{type}/{txn}` | ✅ done | appends a timeline event; `txnId` idempotent |
80+
| `GET /_matrix/client/v3/rooms/{id}/messages` | ✅ done | the stored timeline (`dir=b` default, `dir=f`) |
81+
| `GET /_matrix/client/v3/joined_rooms` | ✅ done | lists the caller's rooms from the container |
82+
| `GET /_matrix/client/v3/sync` | 🟡 stub | full-state only; **no since-token / incremental** — see Findings |
83+
| federation (`/_matrix/federation/*`), join/invite, e2ee, presence, receipts, media | ⛔ not yet | Phase 2+ |
84+
85+
Every response carries wide-open CORS (`access-control-allow-origin: *`) and
86+
`OPTIONS /_matrix/*` is answered locally, so browser clients (Element) can
87+
talk to the shim cross-origin.
88+
89+
## The login → pod-credentials bridge
90+
91+
Matrix's `access_token` is just a bearer the client resends. A Solid pod's
92+
access token is *also* just a bearer. So the bridge is direct — no token of
93+
our own, no session store:
94+
95+
| Matrix step | What the shim does |
96+
|---|---|
97+
| `POST /_matrix/client/v3/login` `m.login.password` | loopback `POST /idp/credentials` with the username+password → return the pod bearer as `access_token` |
98+
| every later `Authorization: Bearer <token>` | forwarded verbatim: `getAgent()` resolves it to the WebID; room/message writes are loopback LDP PUTs under that same bearer, so **real WAC**, not this shim, decides what lands |
99+
100+
There is no `refresh` cycle and no device store: the pod bearer is the whole
101+
identity. `device_id` is synthesized per login and echoed back for clients
102+
that require the field.
103+
104+
## Identity & object derivation
105+
106+
- **`user_id` = `@<pod>:<host>`**`<pod>` is the first WebID path segment
107+
(`/alice/…``alice`), `<host>` is the homeserver `server_name`
108+
(`new URL(baseUrl).host`). Single-user WebIDs (`/profile/card…`) fall back
109+
to the host label.
110+
- **`room_id` = `!<localpart>:<host>`**`<localpart>` is an opaque
111+
base64url id minted at creation and also the pod filename. A room maps onto
112+
one JSON resource `<pod>/matrix/rooms/<localpart>.json` holding
113+
`{ room_id, creator, name, topic, created, events: [...] }`. The first event
114+
is a synthetic `m.room.create`, by Matrix convention.
115+
- **`event_id` = `$<opaque>`** — the event-id v3+ grammar (no domain part).
116+
Every `send` appends `{ event_id, type, sender, content, origin_server_ts,
117+
txn_id }` to the room's `events` array; the `txn_id` makes a re-sent event
118+
idempotent (same `event_id`, no duplicate).
119+
- **Write auth is honest.** Room/message writes are loopback LDP `PUT`s
120+
carrying the caller's own bearer, so real WAC governs them: a client can
121+
only write to a pod it actually controls, and a 401/403 from WAC surfaces as
122+
`M_FORBIDDEN`.
123+
124+
## Findings
125+
126+
### 1. Matrix's fixed `/_matrix` root doesn't fit the one-prefix plugin model — the Nth confirmation, now for a chat protocol
127+
128+
The same seam `mastodon/` (`/api`+`/oauth`) and `bluesky/` (`/xrpc`) found,
129+
confirmed a **third** time and for a **different protocol family** (real-time
130+
chat, not microblogging). A Matrix client hits **fixed absolute paths** under
131+
`/_matrix/client/...` that no client will let you relocate under a plugin
132+
`prefix`. Two things follow, identically to the microblog shims:
133+
134+
- **Routing works.** The loader does not confine a plugin's routes to its
135+
prefix (`api.fastify` is the real scoped instance), and these static/param
136+
routes outrank core's LDP `GET /*` wildcard on Fastify's specificity
137+
ordering. Registration is fine.
138+
- **Authorization does not.** `/_matrix` is an ordinary pod path, not
139+
`/.well-known/*` (which core blanket-exempts). The WAC hook skips only paths
140+
in `appPaths`, and the loader pushes a plugin's **single `prefix`** there.
141+
A plugin has no way to push more roots — there is no `api.reservePath()` /
142+
`api.appPaths.add()` — so it **cannot self-exempt its own surface**, and
143+
every unexempted client call is 401'd by WAC before the handler runs.
144+
145+
The honest consequence: this shim is only usable if the **operator** widens
146+
`appPaths` by hand (`appPaths: ['/_matrix']`). That the finding now holds
147+
across **social (`activitypub`) → microblog (`mastodon`, `bluesky`) → chat
148+
(`matrix`)** is the point: the one-prefix model can't express *any* app whose
149+
routes are dictated by an external protocol at a fixed absolute root,
150+
regardless of protocol family. An `api.reservePath()` surface would close it
151+
uniformly.
152+
153+
### 2. The `/idp/credentials` token bridge generalizes to a 4th protocol
154+
155+
The same loopback bridge established by `notifications/`/`webdav/` and reused
156+
for OAuth (`mastodon`) and XRPC sessions (`bluesky`), now put to Matrix's
157+
`m.login.password` login. The shim has **no user store and mints no token of
158+
its own**`POST /_matrix/client/v3/login` calls the host's own
159+
`/idp/credentials` over loopback and returns the pod bearer verbatim as the
160+
Matrix `access_token`. Because a Matrix token and a Solid bearer are the same
161+
kind of thing (a value resent in `Authorization`), the two worlds join with
162+
zero impedance, and `getAgent(request)` resolves every later call — so the
163+
shim never decides identity, the server does. **Four protocols
164+
(ActivityStreams login, Mastodon OAuth, AT-Protocol sessions, Matrix login),
165+
one bridge:** authentication translation is a general property of the loopback
166+
+ `getAgent` pair, not a per-protocol trick.
167+
168+
### 3. LDP ↔ Matrix-event mapping: timelines fit, the `/sync` since-token model does not
169+
170+
- **Timelines map cleanly onto an append-only resource.** A Matrix room's
171+
timeline is an ordered event log; storing it as a growing `events: [...]`
172+
array in one pod JSON resource, with `event_id`s minted at append time,
173+
round-trips `/rooms/{id}/messages` and `/joined_rooms` faithfully. Read-time
174+
work only: `/messages` is one `GET`, `/joined_rooms` and `/sync` walk the
175+
`matrix/rooms/` container (one `GET` + one per room — the same N+1 /
176+
`api.events` write-index seam `sparql/` and `mastodon/` note).
177+
- **The `/sync` since-token model is the wall.** Matrix's core client loop is
178+
a long-poll `GET /sync?since=<token>` that returns *only what changed since
179+
that token* and blocks until something does. That needs **server-side,
180+
per-device sync state** (a cursor the homeserver tracks for each client) and
181+
a **live push channel** to wake the long-poll on a write. This shim is
182+
stateless (identity is the pod bearer, storage is the pod) and reacts to no
183+
pod write — so it can only implement a **full-state, non-incremental
184+
`/sync`**: it ignores `since`, returns every room's whole timeline, and
185+
hands back a placeholder `next_batch`. That is correct-but-inefficient for a
186+
first fetch and wrong for a delta. A real `/sync` wants exactly the two
187+
things the api lacks: **write-time reactivity** (the missing
188+
`api.events.onResourceChange`, which would also power live push over
189+
`api.ws.route`) and a place to keep per-device sync cursors (server-side
190+
plugin state keyed by device — `pluginDir` could hold it, but only
191+
`api.events` can keep it fresh). This is the sharpest "stateless bridge vs.
192+
stateful protocol" gap the shim family has hit: the microblog shims'
193+
timelines are pull-only, so they never needed it; Matrix's push-shaped core
194+
makes the seam unavoidable.
195+
196+
## Run
197+
198+
```
199+
cd .. && node --test --test-concurrency=1 matrix/test.js
200+
```
201+
202+
12 tests, all green: versions (public) → login (bad password 403) → whoami
203+
(anon 401) → createRoom (unauth 401, resource lands in the pod) → send
204+
(idempotent txnId) → messages (contains the message) → joined_rooms → sync
205+
stub.

0 commit comments

Comments
 (0)