|
| 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