| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
parent directory.. | ||||
Point a real Mastodon client at a JavaScript Solid Server and log in with your pod credentials, post (with photos), reply, favourite, boost, and read your notifications. This is Phase 2 of #515 — Phanpy-grade parity, local-only: the target flow is point Phanpy at this server → log in → home timeline renders → post a photo → favourite/boost → the notifications tab shows follows/likes/mentions. No federation happens in this plugin; everything moves over loopback, and delivering Like/Announce/Follow to remote servers is Wave B (see Findings).
plugins: [{
module: 'mastodon/plugin.js',
config: {
baseUrl: 'http://localhost:3000', // public origin (status URIs + loopback)
loopbackUrl: 'http://127.0.0.1:3000', // optional: where the shim reaches the host
title: 'My JSS', // optional: instance title
podsRoot: './data', // optional: local-user discovery (public timeline)
apRoot: '/ap', // optional: where activitypub/ is mounted
},
}]
No appPaths needed: since JSS 0.0.219 the plugin claims and WAC-exempts its two fixed roots itself at activate time via api.reservePath('/api') / api.reservePath('/oauth') (#602) — see Findings for the gap this closed, and for how the Phase-2 PUT/DELETE widening avoids the fall-through trap.
Mastodon clients ask for a server URL and then run OAuth. Give the client your JSS origin (http://localhost:3000). It will:
Web clients like Phanpy / Elk and the password-grant path both work. For the password grant the client (or you, by hand) posts your pod username
# 1. register an app
curl -s localhost:3000/api/v1/apps -d client_name=cli -d redirect_uris=urn:ietf:wg:oauth:2.0:oob
# 2. log in (password grant → pod bearer)
TOKEN=$(curl -s localhost:3000/oauth/token \
-d grant_type=password -d username=alice -d password='…' | jq -r .access_token)
# 3. post a photo
MEDIA=$(curl -s localhost:3000/api/v2/media -H "Authorization: Bearer $TOKEN" \
-F file=@cat.jpg | jq -r .id)
curl -s localhost:3000/api/v1/statuses -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d "{\"status\":\"cat\",\"media_ids\":[\"$MEDIA\"]}"
# 4. read it back (Link headers drive pagination)
curl -si localhost:3000/api/v1/timelines/home -H "Authorization: Bearer $TOKEN" | grep -i '^link:'| Endpoint | Status | Notes |
|---|---|---|
| `GET /api/v1 | v2/instance` | ✅ done |
| POST /api/v1/apps, GET /api/v1/apps/verify_credentials | ✅ done | persists to pluginDir/apps.json; vapid_key stub |
| GET/POST /oauth/authorize | ✅ done | login page + headless shortcut; PKCE challenge stashed |
| POST /oauth/token | ✅ done | password, authorization_code (+PKCE S256/plain verify), refresh_token, client_credentials |
| POST /oauth/revoke | ✅ done | acknowledges; pod bearer stays the IdP's concern |
| GET /api/v1/accounts/verify_credentials | ✅ done | CredentialAccount with source |
| GET /api/v1/accounts/:id, lookup, relationships, :id/statuses | ✅ done | Account.id == username; statuses paginate with Link |
| POST /api/v1/accounts/:id/{follow,unfollow} | ✅ local | recorded in pluginDir state; delivery is Wave B |
| POST /api/v1/statuses | ✅ done | + in_reply_to_id → Note.inReplyTo, media_ids → Note.attachment |
| GET /api/v1/statuses/:id, :id/context | ✅ done | context = inReplyTo chains over local statuses |
| DELETE /api/v1/statuses/:id | ✅ done | owner-only; loopback DELETE, WAC decides |
| POST /api/v1/statuses/:id/{favourite,unfavourite,reblog,unreblog} | ✅ local | pluginDir state; counts/flags in entities; Like/Announce delivery is Wave B |
| GET /api/v1/statuses/:id/{favourited_by,reblogged_by}, /api/v1/favourites | ✅ done | from the same state |
| POST /api/v2/media (+ /api/v1/media) | ✅ done | hand-rolled bounded multipart; loopback PUT to <pod>/public/media/ |
| GET/PUT /api/v1/media/:id | ✅ done | PUT updates the description (alt text) |
| GET /api/v1/timelines/home | ✅ done | own posts + followed local users; ?limit/max_id/since_id + Link |
| GET /api/v1/timelines/public | ✅ done | all local users when podsRoot is set; else degrades to own posts |
| GET /api/v1/notifications | ✅ done | maps the AP inbox (activitypub/ plugin) — Follow→follow, Like→favourite, Announce→reblog, Create(reply/Mention)→mention |
| stubs (custom_emojis, filters v1+v2, lists, bookmarks, follow_requests, conversations, scheduled_statuses, announcements, mutes, blocks, trends, peers, directory, markers, preferences, search v1+v2, tag timelines) | ✅ empty shapes | 200 + CORS so Phanpy renders instead of erroring |
| GET /api/v1/streaming/health | ✅ done | OK; no ws streaming — Phanpy falls back to polling |
| status edit, polls, bookmarks (real), account update | ⛔ not yet | see Findings |
Every /api + /oauth response — including 401s and preflights — carries wide-open CORS (access-control-allow-origin: *, expose-headers Link), because a 401 without CORS looks like a network error to a browser client.
Mastodon's access_token is just a bearer the client resends. A Solid pod's access token is also just a bearer. So the bridge is direct — no token of our own, no session store:
| Mastodon step | What the shim does |
|---|---|
| POST /oauth/token grant_type=password | loopback POST /idp/credentials with the username+password → return the pod bearer as access_token |
| GET/POST /oauth/authorize | mint a pod bearer the same way, stash it (with the PKCE challenge) under a one-time code, redirect ?code=… |
| POST /oauth/token grant_type=authorization_code | look the code up, verify PKCE if a verifier arrives, return the stashed pod bearer (one-time) |
| grant_type=refresh_token | in-memory map back to the same pod bearer |
| grant_type=client_credentials | an app-only token that carries no pod identity — getAgent() resolves it to nobody, so any write is refused |
| every later Authorization: Bearer <token> | forwarded verbatim: getAgent() resolves it to the WebID; pod writes are loopback LDP PUT/DELETE under that same bearer, so real WAC, not this shim, decides what lands |
The headline seam, and a sharper version of nip05/'s well-known-path finding. A Mastodon client hits fixed absolute paths — /api/v1/…, /api/v2/…, /oauth/… — that no client will let you relocate under a plugin prefix. Two things follow:
Phase 2 widened /api to PUT + DELETE (media description update, status delete) — and that walks straight at core's documented trap: an exempted verb with no matching route falls through to LDP's write wildcards as an unauthenticated storage write. The mitigation is structural: catch-all PUT /api/* and DELETE /api/* routes answer 404 (with CORS) for every path the plugin does not implement, so nothing under /api can reach LDP's PUT /*/DELETE /*. The test suite asserts exactly this (PUT /api/v1/no-such-surface/xyz → the shim's JSON 404, nothing written). /oauth stays POST-only.
Same loopback pattern notifications/ and webdav/ established, put to a new use: authentication translation. The shim has no user store and mints no token of its own — POST /oauth/token calls the host's own programmatic credentials endpoint (/idp/credentials, the CTH-compat endpoint) over loopback and returns the pod bearer verbatim as the Mastodon access_token. Because Mastodon's token and a Solid bearer are the same kind of thing (a value resent in Authorization), the two OAuth worlds join with zero impedance. Every subsequent call re-presents that bearer and is resolved by api.auth.getAgent(request) — so the shim never decides identity, the server does. PKCE rides on top for free: the challenge is stashed with the one-time code and checked in-plugin with node:crypto.
New Phase-2 finding. Status.id is base64url of the Note URL; on a 127.0.0.1:<port> origin with a uuid filename that is > 100 characters — and find-my-way (Fastify's router) silently stops matching a :id param longer than maxParamLength (default 100). The symptom is nasty: the route exists, short ids match, long ids 404 at the router before any handler runs. Plugins cannot raise maxParamLength (it is a createServer-time Fastify option). Wildcard segments are exempt from the limit, so every id-addressed route here is a small dispatcher on GET|POST /api/v1/statuses/* and GET|PUT /api/v1/media/* instead of :id params. Anyone building URL-derived ids on this plugin api will hit the same wall.
GET /api/v1/timelines/public?local=true wants "all local users". The LDP root does not enumerate pods — GET / serves an HTML landing page, not a container listing (probed) — and the plugin api exposes no user enumeration. So the plugin takes the same optional config.podsRoot filesystem hint webfinger/ and nip05/ already take (a directory is a pod if it contains profile/). Without it the public timeline degrades to the caller's own posts and single-user deployments lose nothing. The same knob bounds context-thread descendant walks. (A future api.pods() seam would close this for all three plugins at once.)
The notifications tab is built by fetching GET {apRoot}/<user>/inbox?page=true over loopback with the caller's own Bearer (the activitypub/ plugin's owner-authed inbox collection) and mapping raw AS2 activities to Mastodon Notification entities: Follow→follow, Like→favourite (liked status resolved locally), Announce→reblog, Create{Note} with a Mention tag or an inReplyTo pointing at the user's statuses→mention. Unresolvable items are skipped, never 500s. Remote actors are rendered from the actor URL alone (user@host parsed out of it) — no outbound fetch. Two plugins compose over loopback + a shared pod layout with zero direct coupling.
Unchanged from Phase 1: no account metadata without fetching profiles (placeholders), N+1 loopback reads per timeline page (bounded by limit ≤ 40; a server-side index via api.events is the scale answer), and ids are the shim's convention (a Note not named <digits>.jsonld is skipped). Write auth stays honest: every pod mutation is a loopback PUT/DELETE under the caller's own bearer, so WAC — not the shim — decides.
| Back | FazBrowse Home | New Git URL |