Adds Mastodon as a sixth publish target — the first of the "teased in the UI but stubbed" networks to go live.
Why app-owned
The published @stacksjs/socials ships no Mastodon driver, so this is a full app-owned implementation (app/Services/Social/Drivers/MastodonDriver.ts) rather than a re-export wrapper. Keeping it in app code means it survives framework updates (the lesson from the whole vendored-socials saga).
What's added
- Token-based connect — instance URL + a personal access token (no OAuth dance).
verify_credentialsvalidates the token and captures the@user@instancehandle. Env fallback viaMASTODON_INSTANCE_URL/MASTODON_ACCESS_TOKEN. - Driver —
statusespublish,v2/mediaupload (bytes or fetched URL, up to 4), thread reply-chaining viain_reply_to_id, 500-char limit, instance-URL normalization (mastodon.social→https://mastodon.social), typedMastodonApiError. - Wiring — crosspost publisher map, providers status (composer toggle appears automatically), routes
/postline/mastodon/{status,connect}, and an Accounts connect card (instance + token inline form).
Verification
- 8 driver unit tests (mocked HTTP): verify, plain publish, media upload +
media_ids, threadin_reply_to_id, char-limit guard,MastodonApiErrormapping, instance normalization - Full end-to-end against a local mock instance through the real stack — connect → single post → 2-segment thread where toot-2 correctly
in_reply_totoot-1 → media path, all landing on the mock - Browser: composer shows an enabled Mastodon toggle; Accounts card shows connected (
@glenn@localhost:3055); 0 console errors - 36 tests total;
tscexit 0;pickierclean
Real-instance posting needs the user's instance + token (same as Bluesky), but the full path is proven against the mock.