Changelog
What's changed here, newest first, from the project's history.
A stand-in for a post a thread can't fetch, with the replies to it under it
Opening a thread fetched the conversation, but a post in it that couldn't be fetched (its server down, or refusing us) took everything under it with it: the replies to it were stored, yet the walk down from the first post never reached them, and nothing said so.
Remote posts now keep the conversation they say they're in (`context`, FEP-7888). On the page of the post that starts it, the posts of the conversation that weren't reached from it show under a stand-in for the post they reply to, which links to where it should be. Pages are told live of such replies too.
A post's author, cached but stale, is kept when fetching them anew fails, rather than the post being dropped. And what conversation loading can't fetch or keep is logged, with the reason.
Remove following section from sidebar
Every page opens the same way: a title, its lede, and tabs in line with the panels
A page's head was built anew on each page: Explore sat lower with an italic title and a serif lede, the changelog had a muted intro, a tag had a small note four pixels under its title, Settings and Messages put tabs straight under the title, each with its own spacing. Now there's one .page-head (title, lede, and a form at the end of the title's line when something acts on the whole page), one .page-tabs for the tabs under it or over a feed, and a .section-note for the line under a section's title.
On a tag's page, "Elsewhere" sat flush against the posts above it: its title was the first child of its section, which drops a title's gap.
The tags you follow in the home sidebar, and a tag's page asks the neighbours for it
A trending tag's page was empty: the neighbours were only asked for the tags someone here follows. Now, when someone logged in opens a tag's page, it asks every neighbour for the tag at once, waiting six seconds at most, and keeps what came in the same cache; not more often than every quarter of an hour for a tag, and not for visitors, so that a crawler can't make the server pester its neighbours. The home page's sidebar lists the tags you follow, with Unfollow, and the tag-run styles move to the shared stylesheet for it.
The way back to the top floats above profiles and Explore too
The pill above the feed is FeedPill now: given a live topic it offers new posts as before, and without one it's only the way back up, which profiles and Explore get.
A way back to the top, in the pill that offers new posts
Once the home timeline or a tag's page is scrolled a screen or more down, an arrow floats above it in the same frosted pill as the offer of new posts; each part shows on its own. The arrow scrolls the page back up and hides once it's there.
Theme and language are a settings page; the footer only links to it
The footer carried three pickers. They're now Settings → Theme and language, at /settings/appearance, the last of the settings tabs. The page is open to anyone: logged out, it's there without the tabs, and the footer links to it, beside the changelog, for everyone. Its hints say the theme is for this browser and the language is kept for the account once you're logged in. The script that switches themes in place moved from the layout to the page.
Settings are five pages, with tabs between them
The settings page had grown to eight sections down one long page. They are now pages of their own under /settings, with tabs: Profile, Logging in (confirming it's you, passkeys and the password), Notifications, Apps (tokens) and People (the follow list, blocked and muted). /settings goes to the profile, and the links to the old page's sections (the account menu, Edit profile, the notifications page, follow lists, the browser extension's options) go to the page in question. Each page carries only its own data and script; the heading, the tabs and the reload they all need are in a SettingsLayout.
On the way: changing and setting a password are one form, blocked and muted one list drawn twice, and a new token's cookie is only sent to /settings/apps. Hidden is hidden in the base styles now, whatever display a label or button gets: the chime checkbox showed while notifications were off.
The README is the way in; the rest is on the wiki
The README had grown to twelve hundred lines. It keeps what gets someone started: what's in the repository, getting started, what it does in a paragraph, and the commands. The reference sections (features, board games, the extension and API, configuration, deploying, testing, how it works, starting a service, writing a module and its notifications, live updates and languages, styling, the installed app, identity and the data model, migrations, the security notes, the roadmap and the code tours) are now pages of the repository's wiki, one each, and the README lists them. The code tours, the compose file, the extension's README and two plans pointed at those sections and now point at the wiki; the games tour's "Adding a game" step stands on defineGame instead. AGENTS.md says where the documentation is and that a change keeps the wiki in step.
Tags on articles, written in a field of their own
An article's tags are written at /write in a field under the summary (Fungi, #mushrooms), not found in its text as in a short post: a #tag in running prose reads worse in an article, and code, links and headings would have to be skipped. They're kept as written on the article, by name in post_tags like a short post's, and sent as Hashtags on the Article, in its Create and in the Update of an edit that changes them. What can't be a tag (digits alone, a dash) is refused on publishing.
They show under the article on its page, as article:tag meta for link previews, on its card in timelines and on profiles, and as categories in the Atom feed. Articles from elsewhere show the tags they were sent with on their cards too. An article is on its tags' pages and in the home timelines of those following a tag, and a new article, or an edit that gives or takes a tag, tells the open pages concerned.
Explore is shaped like the home page: a feed with tabs, and a sidebar
The main column shows one feed at a time, Popular here or Trending nearby, with tabs between them when there are neighbours. The short things go in the sidebar: finding someone, the tags in use, the trending tags and links, who others here follow, and the people here. On a phone the sidebar follows the feed.
The Explore plan says what's built and what was tried
Explore: trending nearby, and a tag's posts from elsewhere
A card for a post from a neighbour server (ExploreCard): who, when, the text cut short, its tags, where it's from, and Open, which fetches it through /authorize_interaction logged in and leads to it logged out. A content warning folds the text; nothing of the neighbour's media loads. On /explore, "Trending nearby" with the neighbours' tags, posts and links, once there are neighbours; on a tag's page, "Elsewhere" with the newest under it from them, in place of the links to the tag there. The viewer's blocks and mutes apply to cards by handle. #67
Trends from neighbour servers, fetched into a cache
A background task in the microblog asks each neighbour, every quarter of an hour, for its trending posts, tags and links and for the newest posts under each tag anyone here follows: Mastodon's API, or a tag's RSS feed where the timeline is kept from visitors. What comes goes into explore_items for two days, never into remote_posts: public posts only, no replies, nothing from blocked domains. A neighbour that refuses it all is left alone for a day; one that couldn't be reached is tried next run. Tried against mastodon.social and fosstodon.org from a scratch server. #66
Neighbour servers: the table, and the admin section to pick them
The servers Explore will ask what's trending (docs/plans/explore.md): a neighbours table in core with how each one last answered, add, remove and list, and the state the asking will keep (answered, refused and left alone for a day, not answering after three refusals). A blocked domain can't be one, and blocking a domain drops it. Admins add and remove them on /admin, which says what's asked of them and how often. #65
startTasks takes a service without modules, as the middleware test makes one
Modules run background tasks: the core part of the feeds branch
BackgroundTask on the module API and startTasks/stopTasks in core, from the feeds branch as they were: each task runs ten seconds after the server starts, then again everySeconds after a run finishes, never overlapping; errors are logged; a reloaded dev server replaces the tasks of the service before. The middleware starts them. The feeds module and the timeline cards stay on the branch (#8). #64
Explore: people others here follow
listFollowedHere in core: the remote accounts followed by two or more people here, most followed first, leaving out what the viewer follows or blocked and blocked domains. On /explore with how many follow them and, logged in, a Follow button. #63
Explore: tags in use, the tags with the most posts this week
tagsInUse in the microblog counts the posts that start a thread, here and from elsewhere, over the last seven days, most first and by name among equals. On /explore as a run of tags with their counts, each a link to its page and, logged in, Follow or Following. #62
Explore: popular here, the posts most liked and boosted this week
popularPosts in the microblog: local posts by what anyone did to them, stored remote posts by what people here did, over the last seven days, most first and newest among equals; only posts that start a thread, and the viewer's blocks and mutes applied before the cut. Shown on /explore when there's anything. The pinned-posts tests set their posts' times, so two made in one millisecond can't swap. #61
Explore: a page for everyone, with finding someone and the people here
/explore, a place in the menu after Home. Logged in, it has the follow box and a box that opens a post or an account from any server (through /authorize_interaction); logged out, it says to log in. The people here move to it from the welcome page, which links to it. What's popular here, the tags in use, who others follow and trends from neighbour servers come with the rest of the Explore milestone (docs/plans/explore.md). #60
Plan for Explore: a page from what's here, then trends from neighbour servers
docs/plans/explore.md. Phase 1 is the page from what the server has; phase 2 polls servers an admin picks into a cache, which also answers where a followed tag's posts from elsewhere come from (#32). Filed as #60–#68 in the Explore milestone.
Pinned posts: up to five on top of the profile, as the featured collection
A post's menu now pins it to the top of its author's profile, and unpins it: own posts and self-replies, not replies to others, and at most five, as on Mastodon. The pinned show first on the profile, newest pin on top, marked as pinned, and not again further down; the short-posts and articles tabs keep those that fit.
On the wire they're the actor's featured collection, which the actor now points to as `featured`: core serves it at /users/{id}/featured from a new `featured` hook modules can give, and the microblog lists its pinned posts there. Followers are told with an Add to the collection when a post is pinned and a Remove when it's unpinned, so Mastodon and the like show them pinned too. The JSON API has /api/v1/posts/pin and /unpin, and a post view says whether it's pinned.
Boosts show on the profile
A local account's profile feed listed only their own posts; what they boosted went to their outbox and followers but never showed on their page. The feed now merges their boosts in, newest first, each at the boost's time and marked as boosted by them, filtered like the rest by the short-posts and articles tabs. A boosted post that's gone from here is left out, and their own post they boosted shows once.
The new-posts offer is frosted glass
Floating over the feed, it blurs what's under it (20px), under a tint of the surface colour the ink is set against, so the text stays readable whatever scrolls by: a blur alone fails where the ground matches the ink.
The new-posts offer stays in view, and taking it goes to them
The button offering posts that came while reading now sticks to the top of the window as the list is read, so it needn't be scrolled back to. Taken from further down, it brings the top of the list into view once the new posts are in, rather than leaving them above the window.
Content warnings: a post folded behind a spoiler, here and on the wire
A short post can have a content warning, as on Mastodon: the whole post is hidden behind it (the warning and Show more are all that shows) until that's clicked. It goes as the Note's `summary`, as plain text, and marks the post sensitive, as Mastodon does; a Note that arrives with a summary is folded the same way, and follows its Updates. An article's summary stays its excerpt. Behind a warning, the media and the link card aren't behind another click.
The composer gets a Warning tool that brings up the field (always there without JavaScript); the JSON API takes `warning`, and gives it back on posts. A notification of a post behind a warning, and its page's description, tell only the warning, as the browser extension's popup does.
The UI in Norwegian (bokmål) and English
Every page, form and notice reads in the language picked, or the one the browser asks for: the app's pages, the microblog, messages and games, core's and @allheim/astro's errors and notices, the browser scripts, the service worker's offline page and the web app manifest. Each package keeps its texts in texts.ts (the app in src/texts/), English and Norwegian side by side.
Notifications, a follow list's failures and a game's line in the timeline are kept in every language, so each reader gets theirs; a game's own words (Sjakk, hvit, svart, sjakkmatt) are the game's to give, with `words` on its definition. What's sent to other servers is unchanged: a game's posts and replies stay in the English the other side reads. The JSON API, the CLI and the browser extension stay English.
Languages: the plan, and dates in the reader's
docs/plans/i18n.md says where each package keeps its texts, what stays English, and which Norwegian words the pages use. timeAgo and fullTime (microblog) and shortTime and fullTime (messages) take the language. Texts are in texts.ts in each package, and src/texts/ in the app.
Languages: what the UI needs to be in Norwegian and English
Core's i18n.ts has the languages (English and bokmål) and how a package keeps its texts: an object per language, the English giving the shape. A page's language is the account's pick, then the browser's (a cookie), then its Accept-Language, then English, on Astro.locals.locale and <html lang>. The footer picks it, beside Theme and Mode. An ActionError and a notification can carry their text in every language: errors show in the page's, and pushes go in the language the account picked, or the one of the page it subscribed to them on. The layout's texts are translated; the rest is still English.
Write the changelog after each commit, with a git hook
.githooks/post-commit runs scripts/changelog.ts and amends only the changelog into the commit, so each commit lists itself; not while rebasing, cherry-picking or merging. `pnpm install` turns it on, setting core.hooksPath in its prepare script.
A changelog page, from the git history
/changelog lists every commit, newest day first, its message behind a click; the footer links to it. `pnpm changelog` writes it to apps/web/src/changelog.json, which is committed, since the Docker build leaves .git out. Trailers, git's comment lines and lines that only close an issue are left out; the subject is the message's first line. No hashes are kept, so a commit amended after running it lists itself.
Messages as an icon beside the bell, not a tab
Like and boost in place, without going back to the page
The forms were posted through the router, which followed the redirect and swapped in the whole page with a "Liked." notice. A script now posts them for JSON and turns the button over; a refusal still shows as before, and without an answer the form goes the ordinary way.
Link previews: a card for a link in a post
A post's first link, mentions and tags aside, gets a card under it when the post has nothing attached: the page's title, description, site and picture. It's read from the page's OpenGraph tags, then its oEmbed, then its <title>. Where the page has a known video site's player (its own address, og:video, or its oEmbed's frame), the card plays it on a click. Any other site's markup is never shown.
As decided in #47: a page is fetched the first time a post with it is shown here (a post written here, as it's published), not for every post that arrives. A few are fetched at a time, one per site, and at most 30 an hour from one site. A card that comes while the page is open is put in, under its own live topic (card:{url}), so a timeline doesn't offer it as a new post. The picture is copied here, at most 640 px (storeCardImage), so the site isn't told of each reader. A page with nothing to show is asked again after a day, and cards no one has seen for 30 days go, pictures and all.
Fetching is fetchFromWeb in core (web-fetch.ts): http and https only, to public addresses checked after DNS (on the address actually connected to) and on each redirect, within a time limit and a cap on what's read, and only the types asked for. fetchLinkPreview (link-preview.ts) reads pages with htmlparser2, now a dependency of core. Instances made with allowPrivateAddress may fetch from loopback, for tests.
Not for messages (#45).
Let other sites embed a post from here: oEmbed and a page to frame
Each post and article has a page made to be framed by other sites, at its object path plus /embed: the post alone, as anyone sees it, and a link back. /oembed?url=… answers oEmbed (rich, JSON) for a post's page or URI, with a frame of that page, sized for what's in the post (it can't grow without a script on the site), within maxwidth and maxheight. Post pages point to it with <link rel="alternate">.
Only that page may be framed: it sends its own frame-ancestors *, and the middleware leaves X-Frame-Options off a response whose policy says who may frame it. It's shown as to someone logged out, whoever is reading, so a framing site can't learn who that is or act for them; links open in a tab of their own, and so does a click on the post.
Every post here is public so far, so only a post that isn't there is a 404.
Play PeerTube's videos in place
A video from PeerTube plays where it's shown, in PeerTube's own player: its thumbnail becomes the player on a click, in a timeline and on its page, and "Watch on {host}" links to it there. Without a thumbnail it's a card that does the same. The player is worked out from the video's ID (/videos/watch/{uuid} to /videos/embed/{uuid}, on the same server), so nothing is stored or fetched for it.
PeerTube's own player, not the file, as decided in #40: checked against PeerTube 8.2 and 8.3 servers, a plain MP4 isn't always there, and the HLS files may hold the picture without the sound (peertube.tv), which only the playlist says. PeerTube's player plays every setup.
Since PeerTube is on any server, the Content-Security-Policy's frame-src is now https:. Frames are still made only by link-player-client, from addresses worked out here, once clicked, and sandboxed. The click handler takes any link in a [data-link-player] element, so a thumbnail can be one.
Video and players in articles
Articles from elsewhere keep their videos and sound, on the web only, with controls, nothing loaded before Play, and no autoplay, loop or muting. A frame becomes a link to its address, marked data-frame: a known video site's player shows as that site's click-to-load card, and any other frame stays a link. They used to lose all three, silently.
In an article written here, a link to a known video site alone on its line shows as the player, in the editor's preview and once published. What's sent to other servers has the link, since they drop frames.
To do that in an article's HTML and in the browser, the provider list moves from @allheim/astro to @allheim/core (embeds.ts, also at @allheim/core/embeds), with the card as HTML (linkPlayerHtml) and withLinkPlayers, which puts cards in. The card's click handler and its styles are @allheim/astro's link-player-client and link-player.css, which LinkPlayer.astro, ArticleBody and the editor use.
Videos can't be uploaded into an article yet; a link to one plays.
A player for links to known video sites, loaded on a click
A link in a post or a message to a video on YouTube or Vimeo gets a card under it, worked out from the link when it's shown (embeds.ts in @allheim/astro): nothing is fetched or stored, and nothing loads from the site before the click. Then the card becomes the site's player in a sandboxed frame (YouTube's nocookie one), starting where the link says, and plays at once. Without JavaScript, or with a modifier key, the card is a link to the video.
The first link that matches counts, mentions and tags aside. Not in a post with attachments, behind the click in a sensitive one, and not in a message request (#45). The Content-Security-Policy's frame-src names the players' origins, from the same list, and nothing else.
Checked in headless Chromium: both players play in their sandbox, and YouTube takes the strict-origin-when-cross-origin referrer.
Videos in messages
A message takes up to four pictures or one video, as a post does: the form offers videos where the server has ffmpeg, and sendMessage keeps one with storeVideo. It's sent with its preview picture as icon and its length, and deleting the message takes both files.
A video or sound file in a conversation plays there, with the player from #39 in @allheim/astro, which now also takes a path on this site (mediaSource), as messages give our own files. message_attachments keeps a remote video's preview picture and length. A request's videos from another server stay links until it's accepted, preview picture and all, as its pictures do.
lineOf says "Sent a video.", "Sent a sound file." or, for a mix, "Sent 2 attachments." where it said "pictures" for anything.
Attach a video to a short post: picked, kept and sent
A post has up to four images or one video (docs/plans/video.md). The composer takes MP4, MOV and WebM files where the server has ffmpeg, previews the video with its alt text, and says how much of the upload is sent (XMLHttpRequest, as fetch can't). Without JavaScript the file slots take a video too.
storeAttachments in core keeps a video with storeVideo when the caller allows videos (posts do; messages wait for #44), told by its first bytes and refused alongside anything else. HEIC and AVIF pictures, which start like MP4s, stay pictures. post_attachments keeps a video's length; its preview picture is found by its key. The Note sends it as a Document with the preview picture as icon and its duration, and it shows with #39's player. Deleting the post takes both files.
The upload limit per hour now speaks of uploads, not images. The JSON API for posts takes no attachments at all, images included, so it's unchanged. @allheim/core/testing-video makes videos for tests.
The media store keeps videos, and serves files in ranges
storeVideo repacks an uploaded video with ffmpeg, as decided in #41 (docs/plans/video.md): only files that start like MP4, MOV or WebM reach it, ffprobe has to find H.264, VP8, VP9 or AV1 with sound browsers play alongside, and what's kept is the picture and the sound, without metadata, indexed at the front. HEVC and the like are refused, saying why. Its size is as shown, turned when the phone was held on its side. A frame becomes its preview picture, through sharp, under the video's own name, so deleteMedia takes both. At most 40 MB, two at a time; a server without ffmpeg refuses videos (canStoreVideos says so).
The /media route streams from disk and honours a Range, as players need to seek and Safari to play at all.
ffmpeg goes into the Docker image and CI. The tests that store videos make their own and are skipped without ffmpeg; they pass with 5.1 (Debian bookworm, as in the image and CI) and 9.0. Core's test files each get a media store of their own: they cleared one they shared when done, which pulled it from under a video still being stored.
Play video and sound attached to posts from elsewhere
A video or a sound file on a remote post plays where the post is shown, from its own server, with nothing of it fetched before Play. The player is MediaPlayer in @allheim/astro, so messages can use it too (#44); a type browsers don't play by themselves stays a link, as before.
remote_attachments keeps a preview picture and a length for each. The picture is the attachment's icon, embedded or by URL, as WordPress sends it; Mastodon sends none (checked against live posts), so a play mark shows in its place. WordPress sends width and height as strings, which are now read too. The alt text labels the player and shows under it, with the length.
durationSecondsOf moves to core, for a PeerTube video's length as well, and videoLength to @allheim/astro as mediaLength.
Plan uploaded videos and embeds
Decided in #41 and #45: videos are repacked with ffmpeg, one per post at up to 40 MB; players for known sites come first, then preview cards.
Return to the thread after liking a post in it
On the page of a post from another server (/thread?uri=…), actions on its posts returned to the path without the query: /thread alone, a 404.
Emoji by name in articles, display names and bios
A display name, a bio, and an article's title and summary are kept with the emoji when they're saved (updateProfile, saveDraft), as a short post is, so the Update(Person) and the Article that are sent have 👍.
In an article's text the emoji are put in when it's rendered: Markdown has code, where a name stays a name, and its tokens say what's text and what isn't. renderMarkdown does it when given the names of the custom emoji here (which stay as written), and renderArticle gives them; names in code, in a link's address and in an image's alt text stay. The Markdown that's kept and sent as the source is as it was written, the HTML has the emoji, and the editor's preview shows the same. Markdown from elsewhere is rendered as before.
Emoji by name in messages
A message written with :+1: is kept and sent with 👍: sendMessage puts the emoji in before the message is kept, as publishPost does. The box to write in turns a closed name into its emoji and offers the ones that match an open name, with the composer's browser client.
Custom emoji now work in messages written here, which they only did in messages from elsewhere: they're shown as pictures, sent as Emoji tags, and offered among the suggestions. So a name that's a custom emoji here stays as written in a message too, and shows as its picture.
The list's stylesheet is now imported by the components that have a box (the composer, the message form) and no longer by the client: styles a script brings along are lost when the router swaps pages, which left the list unstyled on a conversation's page. And a name closed before the names have loaded is still turned into its emoji once they're here, with the caret left where it had got to.
In the composer, names turn into emoji as you type, with suggestions
Typing the second colon of :+1: puts 👍 there, with the caret after it; undo brings the name back. While a name is open (a colon and two characters), the emoji that match are offered in a list at the caret, the usual ones and the server's custom ones together: arrow keys move, Enter or Tab takes one, Escape closes, and a pointer works too. The box is a combobox with a listbox while the list is open, so a screen reader says what's offered.
It's a browser client in @allheim/astro (emojiNames, for any textarea), which the composer calls, so the message box can use it too. Its logic is in core, where it's tested: which name is open at the caret (openEmojiName in emoji.ts), and with the names, which emoji a closed name is, what to offer and in what order, and the edit that takes one (emoji-names.ts). The names load when a colon is first typed, as a chunk of their own.
Emoji by name in short posts: :+1: is kept and sent as 👍
The usual emoji can be written by name in a short post, with the names GitHub uses. publishPost puts the emoji in place of the name before the post is kept, so what's stored, shown and sent has 👍 itself, in replies and through the JSON API too, and the post's length is counted as kept.
The names are a table in core (@allheim/core/emoji-names, without server imports, for the browser to load when it needs it), generated from the gemoji package and checked in. A name counts when it's set apart from the words around it, since x, on, key and 100 are all names: key:x:value and std::x::y stay as they are, and so does anything in a link. A name that's a custom emoji here stays as written, so the custom one wins. No skin tones.
Say that tea api works, and blockers are native dependencies
The tea login has an access token since 2026-10-02, so tea api reaches issue dependencies and the Actions runs. Blockers between issues are Forgejo's native dependencies, for any issue, and aren't repeated as a "Blocked by" line in the body.
Follow a tag: its posts in your home timeline
A tag is followed from its page, or through the JSON API; nothing is sent to anyone. The posts with it that this server has, from here and elsewhere, then show in the follower's home timeline, each saying which tag brought it: posts that start a thread, not replies. A post that's there for another reason too shows once, and not as its tag's. Mutes, blocks and the timeline's filter apply, the first two before the limit.
The tags someone follows are listed for them alone at /@you/following. A post with a tag tells the timelines of those following it, once each, and the tag's page, which now offers new posts as the home page does (NewPosts, shared by both).
Give each tag a page, at /tags/{name}
The posts with a tag that this server has, from here and elsewhere, newest first, for anyone: tagTimeline in the microblog. Any spelling of the tag leads to one address. The page says that it's only what reached this server, and links to the tag on the servers its posts came from. Tags in posts from elsewhere lead here, not to their author's server.
Older posts are asked for by cursor (a time, and what tells apart posts of one moment), and the accounts a viewer muted or blocked are left out in the queries, so a page is full while there are posts to fill it.
Unlisted posts get no tags kept, which keeps them off these pages.
Keep the tags of posts from other servers
A remote post's Hashtags are now kept in remote_post_tags, by the same names as tags written here (#Foo, #foo and foo are one tag), however the post got here: delivered, boosted, fetched for a thread or looked up. An Update replaces them and they go with the post.
A post sent with more than ten tags has none kept, since posts stuffed with tags are how tags' pages get spammed, and names that couldn't be tags here are left out. The posts already stored get theirs in the migration, from the links to tags in their content.
Make a #tag in a short post a tag: linked, kept and sent
A #tag written in a short post or a reply, in any script, is now found (not inside a word, a web address's fragment or a character reference, and not if it's all digits), linked to /tags/{name} in the markup Mastodon uses, kept by name in post_tags, and sent as a Hashtag on the Note. #Fediverse and #fediverse are one tag, shown as written. The posts already here get their tags in the migration.
For that, withGenerated takes functions to run after a generated migration's SQL: a hand-written migration after generated ones would have renumbered them.
The tags' pages come with #30, so the links lead nowhere yet.
Keep the box to write in in view, and a reader's place in a conversation
On a phone the box to write a message in was below the fold, on opening a conversation and after each one sent, and a swipe in the messages didn't move the page to it. The messages and the box now fit the window together, the page opens scrolled to them (unless it shows an error or a notice), and a swipe past either end of the messages goes on to the page.
A message that came while you read older ones threw you to the newest: the pane itself was replaced. What's in it is now, and the live client holds still what's at the top of a scrolled pane as it does the window.
Keep a message's picture on its sender's side
A picture's width was limited by a percentage, which counts for nothing while a message is sized to what's in it: a landscape picture stretched its message to full width and sat at the left of it, on the other person's side on a phone and in the middle on a wide screen. The limit is now in the thread pane's own units.
Send pictures in messages, and report a message
Up to four pictures a message, with alt text, kept and sent as a post's are; a request's pictures from another server stay links until it's accepted. A message you were sent can be reported, and the report carries what it says, since moderators can't open conversations. Uploads and remote attachments move from the microblog to core, for both modules.
Put messages in the app: pages, forms and live updates
Conversations at /messages, with requests beside them and a form to write to someone new; a conversation's page reads it, and what you were told of it. Messages in the header, Message on profiles. The games claim their servers' own notices, which would otherwise arrive as messages.
Add the messages module: direct messages as private mentions
Conversations between two people, here or elsewhere, kept apart from posts. A Note addressed to one person here and no one else is a message; from someone they don't follow it waits as a request and tells no one. Not in the app yet: it has no pages.
Address posts and claim them from core
Addressing a post and checking who asks for a private mention move from the board games to core, for the messages module to share. A module can claim an incoming post, so those listed after it leave it alone: the games claim challenges and commands, not their players' chat.
Plan direct messages
Give articles from other servers the whole page too
Step through a game's moves on its page
Each move in the list is a link to the board as it was then, as are the steps under the board (start, back, on, latest), and the left and right arrow keys step too. An earlier position is the page with ?move=12, so the links work without JavaScript; with it only the game is fetched, and the address follows without filling the history. The game goes on meanwhile: new moves come into the list, and the board stays where it's being looked at. An earlier board isn't clicked on to play.
Only the current position is kept, so an earlier one is found by playing the recorded moves again from the start: play must take a move as it recorded it. Chess replayed the whole game for every move played, most of a second for a long game: playing a move now hands its position on to the new state, and a move written as recorded is found without reading every legal move's other spellings.
Wait long enough for the first Follow in the follow-list test
The test waited a second for the imported account to be followed, but alice's keys are made on her first Follow, which takes most of a second and sometimes more. Waiting as long as a test may take makes the three tests that failed together every few runs pass every time.
Offer to follow a remote post's author under the post
A post from elsewhere may be the first of its author's someone sees: under it, next to "Open on host", they can follow them there and then, as on a profile, and see and undo the follow once it's made.
Give the replies' title its gap back
The title sits first in the box that keeps the replies current, so the rule that takes a first title's gap away took it. The gap is on the box now.
Offer new posts above the timeline as they come
The home timelines of everyone here following an actor are told when they post, boost or take something back, and an actor's own of their posts. The home page doesn't push them in above what's being read: it counts them and offers them, "3 new posts", which a click brings, with the posts already there staying as they are.
An element marked data-live-held is the general form: a change fires allheim:live-held on it instead of fetching, and data-live-show asks.
Show likes, boosts and edits as they happen
A post's page keeps the post itself current too, with its likes and boosts under it: a like or boost, from here or elsewhere, its undoing, and an edit are told of to the post's page and to the pages above it, where it's a reply. A post's key now says what may change in it (when it was edited, its counts, whether you reacted), so one that changed is drawn anew and the rest stay as they are.
Put new notifications into the open notifications page
A notification that comes while /notifications is open is put in at the top, as new, and one taken back goes. Only while the tab is visible: fetching the page marks what it shows read, which a tab nobody's looking at mustn't; a change meanwhile waits for it. Reading them doesn't refetch the page, so the new ones stand out until you leave, as before.
Keep the games page's lists current
A game that begins or changes is told of to the lists it's in: each player's here, and, for those logged out, the list of what's played here. Games are keyed by when they last changed, so a game that didn't keeps its element, board picture and all, while a challenge, an answer or a move moves a game between "Your move" and "Their move" as it happens.
Tell open pages of a new build as soon as their stream is back
The stream's hello says which build the server is. A page from another one marks itself out of date at once, as it does when the service worker says so: reloading while you're away from it, or offering to. So after a deploy, pages left open catch up as soon as they reconnect, rather than when the browser next looks for a worker.
Show a thread's replies as they come
A post's page keeps what's above and below it current. A reply that comes or goes, from here or elsewhere, is told of to the page of each post above it (posts keep what they reply to, not their thread's root), and a conversation being fetched tells the page it was fetched for of each post as it's stored, and when that's all: "Reload to see it" is no more. Posts are keyed by URI, so those already on the page stay as they are, and the post stays where it is on the screen when what it replies to is put in above it. Threads around our own posts are for anyone to watch; those around posts from elsewhere for people logged in.
Not live: edits, like and boost counts, and a post fetched above another page's post.
Show a game as it's played, to both players and anyone watching
A game's page keeps its board and what's beside it current: updateGame publishes the game's topic, and anyone may watch a game there is. So the other player's move, draw offer or resignation shows as it happens, from here or from their own server, with the move form and click-to-move set up again for the board put there anew.
The games e2e test reads the streams of both servers while they play, logged out on one and as alice on the other.
Tell open pages what changes, starting with the bell
Whatever changes is published under a topic, in memory (core's live.ts), which reaches every open page because the server is one process. Each page holds a stream to /api/live (Server-Sent Events) for the topics of the elements it marks data-live, and a module says who may watch its topics. An event says only that a topic changed: the page fetches itself again and swaps in those elements, so everything is still rendered by the server, for whoever's looking. An element being typed in waits; one with data-live-key inside stays as it is, with what's new put in around it; what's at the top of the window stays there.
The middleware notes the server's clock when a page is asked for, and the layout stamps the page with it, so the stream can start by saying what changed since: nothing is missed between rendering and listening, nor while a connection was down. The stream pings every 25 seconds, ends when the session is over, and says goodbye to a page with nothing to hear or to one too many.
The unread count is the exception, sent as a number whenever it changes, so every tab's bell and title agree, and reading notifications in one tab clears them everywhere. The chime plays from the stream too, once per notification across tabs and across both paths. Web Push stays as it was for when the site isn't open; a page hearing the count no longer counts pushes itself.
The plan, with what's left, is docs/plans/live-updates.md.
Export and import follow lists
Settings has who you follow as a CSV file to download, and takes one from another server, to bring your follows along when you move. The file is Mastodon's kind, which Misskey, GoToSocial, Pleroma and others export and import too; a plain list of handles or URLs is read as well.
Following means finding each account on its server, so an imported list is kept in follow_imports and followed in the background, four accounts at a time, a few of each person's in turn. Accounts already followed are left out without asking their servers. Settings says how many are left, and lists those that couldn't be followed with why, to try again or dismiss. A list the server was stopped in the middle of is carried on with when someone logged in next asks for a page.
A file may be 1 MB, and 10,000 accounts may wait per person.
Give requests that arrive together the same new keys
An actor's keys, and the server's own, are made the first time they're needed. Two requests needing them at the same moment each made a pair, and the second failed to save its own. The first saved is now the one both use.
Keep posts where they are while images and threads load
A post's only image had no room until it was loaded: its width and height were overridden in the styles, so it was nothing tall, and what was under it jumped when it came, in the middle of a page's transition or when a post fetched above another showed its picture. It now takes its room at once. One that doesn't say how big it is gets a box to be shown in. A custom emoji is square until it's loaded.
Under a post whose replies are being fetched, only the title says so. The two skeletons there went away when there were none, and the composer under them jumped up. Above, where what a post replies to can't be had, the post glides up to the skeleton's place rather than jumps.
Keep a tall sidebar in reach, and the following list short
A sidebar taller than the window goes along with the page until its end is in view, and stays there, rather than showing its end only when the page has run out. Its height is set by the layout's script.
Home shows the five followed last, and a link to the rest. There, the one whose list it is sees those still pending too, and can unfollow each.
Centre a profile's buttons on its picture
Colour the installed app's bars in the theme and mode
Firefox on Android colours an installed app's bars from the web app manifest, not the page's theme-color, and the manifest was always Paper in light mode. Each page now links to the manifest in the visitor's theme and mode.
Fetch accounts cached before custom emoji again
Accounts cached before custom emoji were read have none for their names, and stayed that way until their cache went stale by itself. A migration counts them all as fetched long ago, so each is fetched again when it next posts or is looked up.
Sign the fetch of a fetched post's author
A post from a server that only answers signed fetches (Mastodon in secure mode, GoToSocial) was fetched signed, but its author wasn't, so the post was dropped and threads stopped short of it. An author fetched within the hour isn't fetched again.
Open a thread at once, and fill it in as it's fetched
A post's page waited up to three seconds for its conversation to be fetched from other servers before it showed anything. Now it shows what's here at once: if more is being fetched, skeletons stand where it'll be, above and below the post, and what's fetched takes their place when it has been (server islands, which wait up to eight seconds). The post stays where it is on the screen when what it replies to arrives above it.
While any page that's slow to come is waited for, a bar grows along the top.
Show and send custom emoji
Custom emoji, written as :shortcode:, as on Mastodon. Those tagged on posts and profiles from elsewhere are kept with them and shown as pictures, from their own server. An admin adds this server's own at /admin; they're used in posts, articles, names and bios, picked from the composer's list, sent as Emoji tags and served at /emojis/{shortcode}.
Go from page to page with view transitions
Astro's client router is in the layout: pages fade into each other, the header and footer hold still, the current tab slides to the next, and a post that's opened moves to its place in its thread, and back.
Scripts set what's on the page up again after each swap. Logging in, signing up, settings and the article editor are still loaded anew, as is any page once the server's been updated, or when leaving would lose something typed. Nothing is prefetched, since going to a page may do something.
Have the e2e tests' instances listen on 127.0.0.1
On the CI runner the server listened on "localhost" but refused connections to 127.0.0.1, which is where fetch went: the name meant ::1 to the one and the IPv4 address to the other.
Show an instance's log when it doesn't start in the e2e tests
An instance that failed to start wasn't yet among those whose logs are printed, so the tests said only that they timed out. They also stop waiting as soon as the server exits.
Run CI on the runner's ubuntu-latest label
The instance's runner doesn't take the docker label, so the jobs waited.
Run the checks and tests on Forgejo Actions
Every push to main and every pull request runs `pnpm check` and `pnpm test` in one job and `pnpm test:e2e` in another, on Node 24 with the Dockerfile's pnpm.
Tests get 20 seconds instead of 5: each local actor's RSA key is made on first use, which now and then took a test past the default in a container.
Tell agent skills where issues, labels and domain docs live
Issues are on Forgejo (git.velv.no), managed with tea; the five triage labels keep their default names; and there is one glossary and one ADR directory at the root. `tea api` is unauthenticated with an SSH-agent login, so the tracker notes say to use the ordinary commands.
Log in with passkeys, and allow passkey-only accounts
People can sign up with a passkey instead of a password, log in with one from the login page's button or the username field's autofill, and add, rename and remove passkeys in settings. An account can drop its password and use passkeys only, or set a password again later; it always keeps at least one way to log in.
Changing how an account logs in needs a recent confirmation: logging in, or confirming with a password or passkey, counts for 15 minutes (sessions.verifiedAt). Challenges are stored by their value, single-use, for one ceremony and account, and expire after 5 minutes. Browsers are told about passkeys the server doesn't have, so password managers can drop them. `user:password` now removes passkeys too, as it's how an admin takes an account back.
Verification is @simplewebauthn/server's. Tests use a software authenticator (@allheim/core/testing-passkeys) that signs real responses.
Plan two-factor auth and passkeys
Let people change their password in settings
The current password is checked, rate limited per account, and the change signs out every other session while keeping the one it's made from. API tokens stay; settings lists them for revoking.
Give new actors, posts and games short IDs
New rows whose ID ends up in a URL (actors, posts, article drafts, games and game posts) get 16 lowercase letters and digits from nanoid instead of a UUID, so permalinks are much shorter. Existing rows keep their UUIDs, since other servers know them by those, and both kinds work side by side. Activity IDs and media keys stay UUIDs.
Give the profile's buttons room under the header image
Put the profile header on one panel
The header image runs to the panel's edges with the picture over it, then who it is, the bio (keeping its line breaks) and a follow field for visitors, and under a rule the counts, with when they joined on the right.
Center pages that have no sidebar
They keep the main column's width, in the middle of the page rather than under the logo with the sidebar's track left empty.
Add profile header images
A header (banner) image, uploaded in settings next to the profile picture, cropped to 3:1 around its middle at most 1500×500 as Mastodon shows them. It's stored like the picture (actors.header_key), sent as the actor's `image` so other servers show it, and shown at the top of the profile with the picture over its lower edge.
Add follow buttons and a copyable handle to profiles
Signed-in visitors get Follow (or Unfollow / Cancel request) next to the picture, with Challenge to a game and Edit profile as buttons there rather than among the counts. Signed-out visitors can enter their own fediverse address and are sent to their server's remote-interaction page, found through its WebFinger subscribe template (/api/remote-interaction). The handle gets a Copy button, and the Posts heading above the feed tabs goes.
Even out the gap around the follow button
Line the feed filters up with the text in panels
Line section titles up with the text in panels
Inset section titles like the feed filters
Inset the feed filters from the edge
Make the follow form one pill-shaped field with a hint below
The button sits in the field's right end, and a hint says it takes a handle or a profile link.
Show the profile card's counts as boxes, number over label
Each count is its own dimmed box, tinted from the theme's ink so it reads on every theme: two by two in the sidebar, one row when the card is wider. The posts count now links to the profile like the others.
Make the notification count readable on every theme
The count on the bell sat on the header's accent in the accent's ink, which is white in light mode: fine on Paper's rust, but nearly invisible on the lighter accents of Skeuomorphic, Nature, Ukraine, Palestine and Pride. It now uses its own token, --chrome-accent-ink, which is the accent's ink unless a theme sets it; those themes set it dark.
Add Ukraine, Palestine and Pride themes
Ukraine is the flag as a landscape: a blue sky of a header with gold along its edge, wheat growing up the footer and faint sunflowers on the page. Palestine is the flag's black, white and green with its red triangle at the hoist, a keffiyeh's fishnet over the page and olive branches along the footer. Pride is the Progress Pride flag: its chevron at the hoist, the rainbow along the header and footer, and a little of it shining down the page. Each has a dark mode, and the flags' hoists widen into the margin on wide screens but stay clear of the logo on phones.
Drop the home page's section titles, and offer an article in the composer
Put Write before Play in the menu, with a little more room between tabs
Show videos from PeerTube and the like
The inbox only kept Notes and Articles, so following a PeerTube channel showed nothing. Videos are now kept with their title, Markdown description, biggest thumbnail, length and watch page, and show as a card linking there. Remote posts get a kind, so articles no longer have to be told apart by their title.
Show the posts you liked, and how many
A private page at /@username/liked lists the posts someone liked, newest like first, 50 per page. Only its owner sees it (anyone else gets a 404), as likes are private on Mastodon too; posts since deleted and authors muted or blocked are left out. The home page's profile card and your own profile show a "liked" count linking to it, and the account menu has a Liked link.
Give articles the whole page, the sidebar's track too
Add a Nature theme: moss, fallen leaves, a canopy, a hedge and ferns
The page is pale moss with leaves fallen on it, the header is a leafy canopy with a hedge along its bottom edge, and ferns grow up the footer. Buttons are fern green and boosts catch the sun; dark mode is the same wood at night. The footer gets its own background token, --footer-bg, which is the header's unless a theme sets it.
Put Write in the main menu, and call Games Play
Don't flash the composer's no-JavaScript image slots as it loads
Show only the logo in the header, and centre the tabs' text on it
Limit reports, Flags and challenges per hour
Nothing limited how many reports someone here could send, nor how many reports (Flag) or game challenges another server could, with as many accounts as it likes. Take 20 reports an hour from a person, 30 Flags and 30 challenges an hour from a server, and keep at most 20 things per Flag.
Limit how many images one person uploads an hour
Nothing limited uploads: drafts are unlimited, each takes 50 images, and posts 4 each, so one account could fill the disk. Allow 100 images an hour, to posts and articles together.
Decode fewer, smaller images, and only the formats kept
Uploads went to sharp whatever they were, so every format libvips reads (SVG, TIFF, PDF...) was parsed before being refused, and images of up to 100 megapixels were decoded, any number at once. Check the first bytes for JPEG, PNG, GIF, WebP or AVIF first, take up to 50 megapixels (a 48-megapixel photo fits), and decode two at a time.
Don't read big bodies from people who aren't logged in
The server takes 48 MB bodies for uploads, and read that much for the login and sign-up forms and the inbox too, from anyone. Read login and sign-up forms only up to 16 KB, even chunked, and turn away requests from people not logged in that say they're over 1 MB.
Cap how long remote names, handles and posts may be
A remote actor's name and handle and a remote post's content were kept whatever their length, then shown in every timeline. Cut names to 200 characters, keep no handle over 300, and don't keep posts (or edits) with over 500,000 characters of HTML.
Keep conversation fetching to its budget and away from blocked servers
Loading a conversation fetched each post's replies collection without counting it, so it made about twice the fetches it allowed itself, and it fetched collection pages and posts from blocked servers, checking the block only afterwards. Count replies collections, and check the block before every fetch, and before looking up a mention.
Give up on slow remote fetches, and don't fetch what a Create can't be
Fetches from other servers had no timeout beyond undici's five minutes, so a server answering slowly on purpose could keep requests and queue workers waiting. Give every loader that fetches from elsewhere a 15 second timeout (withTimeout), and lookups 30 seconds. And the Create handlers fetched an object on another server than the sender's before finding it can't be the sender's post; drop it without fetching.
Tick L7 in the security audit
Drop activities whose ID is on another server than their actor's
Fedify checks that an activity is signed by its actor, but not that its ID is the actor's server's to give, and remembers IDs to skip repeats. So a server could send its own activities under another server's predictable IDs and have the real ones skipped. Drop such activities before any handler, and don't remember their IDs.
Sign the account out everywhere when its password is reset
user:password only replaced the password hash, so whoever had a session or an API token kept it: resetting the password of a compromised account didn't lock anyone out. Reset it with resetPassword instead, which also ends the account's sessions and revokes its API tokens.
Drop relative links from sanitized HTML, and send bios as HTML
A remote post could link to a page here with words of its choosing in the query, such as /login?error=..., which the page shows as its own alert. Keep only absolute http(s) and mailto links. And the plain-text bio went out as the actor's HTML summary unescaped; send it escaped, in paragraphs.
Page outboxes by date, so a page far back costs no more than the first
The outbox paged by offset, and to merge modules each was asked for offset + 50 fully built activities, so one unauthenticated request for the last page built an account's whole history. Page by date instead: the cursor is a moment and how many items at it earlier pages had, and OutboxSource.list takes that moment instead of an offset. The microblog builds only the activities it returns.
Send security headers with every response
No page said it mustn't be framed, and only media and board pictures turned off content sniffing. Send X-Frame-Options, a CSP with frame-ancestors, base-uri, form-action and object-src, nosniff and a Referrer-Policy with every response that doesn't set its own. Scripts and styles aren't restricted yet.
Make blocks keep what the blocked send out for everyone
A block only hid the blocked account from the blocker: their replies still showed under the blocker's posts for everyone else, their likes and boosts still counted, and they could still move in a game. Don't keep replies, mentions and reactions from someone the post's author blocks, leave their replies (and what's under them) out of that author's threads and their reactions out of the counts, old ones included, and ignore their game commands.
Only push to browsers' push services, and only so many
A push subscription could name any HTTPS host, including one resolving to a private address, and an account could keep any number of them, each encrypted to and POSTed on every notification. Take only endpoints on the browsers' push services, keep one subscription per login session and ten per account, and refuse an endpoint another account's session has instead of taking it over.
Keep /authorize_interaction's redirect on this site
A URI on our origin with a path like //evil.example/x passed the origin check, and its path was sent on its own as the redirect, to another host. Redirect through redirectWith instead, and have safeLocalPath refuse paths a browser would resolve to another host.
Don't let one address lock an account out of login
Every login attempt counted against all three limits, even once the address's own limit refused it, so one address could keep any account at its per-username limit. Check the limits in order and stop at the first that refuses, and count usernames no one can have only per address, so they don't end up in rate_limits.
Keep only public posts from other servers
Remote posts had no audience, so a followers-only reply or a direct message to someone here was stored like any other post and shown under the post it answered, to anyone, and in other people's timelines. Keep only what's addressed to the public, openly or unlisted, and forget a post edited to be anything else.
Write up the 2026-09-28 security audit
Show your notifications in the browser extension
The extension's toolbar button now counts your unread notifications, the popup's bell lists the latest ten (and marks them read, as the notifications page does), and with the new option on, each new one comes up as a desktop notification that opens what it's about when clicked.
The background asks the server every minute, on an alarm, for notifications newer than the newest it has seen; the first time after connecting it only notes the newest, so nothing from before pops up. More than three new at once make one notification that opens /notifications. Desktop notifications are an optional permission, asked for by the checkbox. On a page with a badge of its own (a fediverse profile or post) that badge shows instead of the count; clearing it falls back to the count.
The JSON API gains GET /api/v1/notifications (newest first, with `limit`, `before` and `after`, every address in full, and the unread count) and POST /api/v1/notifications/read {ids}. listNotifications takes `after`, and apiRoute's helpers inputId, for IDs given as numbers or digits.
Make the composer a Preact island, and put one under every thread
The composer now takes images by picking, pasting or dropping them, with previews and alt text; counts characters; grows with its text; posts with Ctrl+Enter; and posts without leaving the page, so an error keeps what was written. Reply links on the page point it at their post. On the server it still renders a plain form that works without JavaScript.
actionRoute answers requests that ask for JSON with the outcome instead of a redirect, which the composer uses. The post limits move to a client-safe compose.ts.
Sign with draft-cavage first, so Pixelfed gets our activities
Fedify's first knock was RFC 9421, falling back to draft-cavage only on an error. Pixelfed answers 200 before verifying and only understands draft-cavage, so Fedify remembered it as taking RFC 9421 while Pixelfed silently dropped every Follow. Knock with draft-cavage first, and a migration forgets the servers already remembered as RFC 9421.
Fetch the rest of a conversation when its thread is opened
A thread only showed what happened to be here: posts by people someone follows, and replies to our own. Opening one now fetches what it replies to (as replying did already) and the replies under it, from its server's context collection (FEP-f228, which Mastodon and others serve) or else by following each post's replies collection down, page by page. What arrives within three seconds shows at once; the page says when more is coming.
Each load makes at most 100 fetches, skips blocked servers, and fetches every post from its own ID. A conversation is loaded at most every ten minutes, and a person may load 60 an hour. Rate-limit windows that are over are now deleted as new ones start, so the per-thread keys don't pile up.
Open a post's thread by clicking it in a timeline
Clicking a post anywhere but on a link, button or its media opens its thread, in a new tab with a modifier key; a game's post opens its game. Its time links there too, without JavaScript.
Posts from other servers get a thread page here, /thread?uri=…, with what's stored of the posts above and the replies under, and a link to the original. findThread builds it for any post that's local or stored; postThread is now that for a local post, and both pages share Thread.astro.
Fix posting from the dev server: run allheim's packages in Vite too
Every post from the dev server failed to sign with "The proofs must be an array of type DataIntegrityProof | URL". @fedify/astro has Vite run Fedify itself, and Astro does the same for packages that depend on astro, but @allheim/core doesn't: the dev server left it to Node, which loaded a second copy of Fedify with it. A Create from one copy then failed the other's instanceof check for its proof. The integration now has Vite inline @allheim/* as well. Built servers bundled them already, and their output is unchanged.
Read articles at readable URLs, with link previews and an Atom feed
An article is now read at /@username/articles/{slug}, made from its title, and that's the url its Article carries; its URI sends browsers there. A new title makes a new slug when the changes are published. Every slug an article has had is kept in article_slugs, so links from before still find it, and no other article of the author's takes one.
Post and article pages describe themselves for link previews with OpenGraph tags and a Twitter card, and name their author with fediverse:creator, which Mastodon shows on link cards. Each person's articles are an Atom feed at /@username/articles.atom, in full, which profiles and article pages point feed readers to.
This finishes the plan's third phase; what's left is under "Later".
Write articles: drafts, a Markdown editor, and publishing them as Articles
Articles are longform posts with a title, written at /write in Markdown. The editor has a preview beside the text, rendered by the same code as what's published, a toolbar that formats the selection and uploads images to where the cursor is, a cover picture, and Ctrl+S to save. Without JavaScript it's a plain form, which adds images at the end of the text. Drafts are a table of their own, so nothing unpublished is served, sent or listed, and editing a published article goes through a draft too, which leaves the article as it was until it's published.
Publishing sends Create(Article) to followers and the people it mentions: title, summary (or the first paragraph), HTML, the Markdown as source, the cover as image, and the images in the text as attachments with their alt text, for servers that strip images from content. Changes go as Update; images no longer used are deleted, as are a discarded draft's. An article is served at /users/{id}/articles/{postId}, where browsers get it in full with the replies to it. Timelines and profiles show articles as cards, and profiles gain the All, Posts and Articles tabs.
Core's mention pattern moves to @allheim/core/find-mentions, so the editor's preview can use it in the browser; mediaKeyOf finds the key in a media URL; and an actionRoute handler can say where to redirect. The e2e test publishes, edits and deletes an article between two servers.
Make the app installable, and keep open pages in step with deploys
The app has a web app manifest and icons (made from favicon.svg by `pnpm icons`), and the header's colour as theme-color, for each theme in light and dark, so browsers can install it as an app with its own window. On iPhone and iPad that's also what browser notifications need, and Settings says so there.
The service worker, now at /service-worker.js (the old name serves it too), is registered by every page rather than only with notifications on. Pages still always come from the server, since they're per login; when it can't be reached, the worker shows a page saying you're offline. It keeps each build's /_astro/ assets, this build's and the one before's.
An installed app may stay on one page for days, across deploys. Each build now gets an ID, the same in the server and the browser bundle, and the worker contains it, so every deploy is a new worker, which takes over at once and tells open pages its build. Pages also look for a new worker when you come back to them. A page from an older build reloads while hidden, unless a form has unsaved input, and meanwhile offers a Reload button.
Read articles from other servers, and filter the timeline by kind
The microblog dropped anything that wasn't a Note, so a followed blog on WriteFreely, Ghost, WordPress or Plume never reached anyone's timeline. Articles are now kept like Notes, with their title, their summary as plain text (or else their first paragraph) and their cover if it's embedded, and show in the timeline as cards linking to where they're read. Their HTML goes through a new sanitiser, sanitizeArticleHtml, which also keeps headings, rules, figures, tables and images on the web. An Article without a title stays a short post. Articles can be found by URL, liked, boosted and replied to like any post, and a mention in one is told by its title.
The home timeline gains All, Posts and Articles tabs. The filter is in the queries, before their limit, so a page of articles is never cut short by the posts around them. The JSON API's posts gain `article`.
docs/plans/blog.md is the plan for longform posts; this is its first phase. Writing articles here comes next.
Keep the code tours in step with the code, add a games tour, and update the README
Many of the architecture tour's steps had drifted to blank lines or the wrong code, and some described plans long since done. Each step now finds its line by a pattern rather than a line number, so it follows the code, and apps/web/test/tours.test.ts fails when a pattern stops matching one line or a file a step names is gone. The tour gains steps for a post's thread page and the JSON API, and leads on to a new games tour, which follows a game of chess from the game package through the referee, a move on the web and one answered from Mastodon.
The README gains "Starting a new service" and a section on notifications for module authors. The module sample served Notes, which the microblog already does, so it would have failed at startup; it's a polls module now. Node 22.18 is the real minimum (node:sqlite unflagged, and running TypeScript directly), so engines says so. The layout, commands, data model, security notes, testing, roadmap and tours sections are brought up to date.
Fix four bugs that new tests found, and test what wasn't
A game whose game is no longer installed made the games module's threads hook throw, and with it the home timeline of everyone who played it. Such games are now left out, and their posts show as posts.
Someone else on a remote post's server could overwrite it here by sending a Create with its id and themselves as author, which also notified people as them. A stored post now only changes for its own author, as Update already required.
A malformed password hash made verifyPassword throw, and login fail with an error, instead of returning false. And blocking a server only deleted notifications from its cached actors, missing those whose sender couldn't be fetched; it now goes by the notifications' own senders.
The new tests cover following and unfollowing, passwords and sessions, actionRoute's login and role checks, CORS on the API only, which module's thread a post is in, notification paging, the referee refusing non-players on the web, withdrawn, declined and muted challenges, a thread that loops back on itself, the 40-post cap on fetching a thread, a post shown once when also boosted, and refused post actions.
Show a post's whole thread, and fetch the posts above a remote one replied to
A post's page showed only its direct replies, so a reply there read out of context and answers to answers were missing. It now shows what the post replies to above it, as far up as those posts are here, and below it the replies to it and to those, depth first and stepped in. With posts above, the page scrolls to the post and outlines it. What the viewer blocked or muted goes with everything under it in the thread.
Replying to a post on another server now also fetches and stores the posts it replies to, up the thread, in the background so the reply isn't held up. It stops at the first post it can't fetch, and after 40.
Tell nothing of refused moves, and chime when a notification arrives
A move the games module refuses (out of turn, or against the rules) is still a post mentioning the player here, so the microblog told them of it as a mention, and each turn could bring two notifications, one for a move that never happened. The opponent is already told why; the games module now takes the mention back with core's new withholdNotification, also when the post comes again. Chat in a game's thread is still a mention.
Open pages now hear of each push from the service worker: the bell and the title count it, and one page (the one you're looking at, if any) plays a short chime, made with Web Audio. Notifications can't choose a sound and a worker can't play one, so when a page chimes, the system notification is silent. The chime can be turned off per browser in Settings.
Add notifications, in the app and as browser notifications
People here are now told when someone mentions them, replies, follows them, likes or boosts their posts, or challenges them or moves in a game, whether from here or another server. Notifications are listed at /notifications, with an unread count on a bell in the header, and pushed to browsers that turned them on in Settings, even while the site is closed.
Core keeps them in `notifications`, and any module can add one with notify(): who did it, what they did, where to see it, and a key naming what it's about, so a redelivered activity tells no one twice and an undo or delete takes it back. While an incoming activity is handled, they wait until every module's handler is done, and those about the same key are one, the last made winning: the games module turns the microblog's "mentioned you" for a move into "played Nf3". Blocked and muted accounts notify no one.
Browser notifications use Web Push. Subscriptions belong to a login session, so logging out stops them; the server's VAPID key pair is made when first needed. web-push builds each encrypted request and fetch sends it, only to HTTPS endpoints on a named host. @allheim/astro serves the service worker and the subscribe endpoints (JSON only, so no other site can post to them), and a small client module for pages.
htmlToText moves from the games module to core, since notifications of remote posts need it too.
Fold a game's moves into one timeline entry, through a module threads hook
Modules can now say which posts belong to a thread they keep (threads.find in the module API, asked through findThreads). The boardgames module answers from each game's thread, and the home timeline shows a thread's posts once, as the newest: "In Chess with Bob · 4 earlier posts · Your move.", linking to the game, instead of one "In reply to a post" per move.
Make the logo an A whose legs cross at the peak, like gable boards
The roof over an "a" becomes a single letter: the legs of the A cross at the top like the gable boards of a Norse longhouse, which reads more clearly at favicon sizes.
Add a logo: a longhouse roof over an "a", as favicon and in the header
The roof boards cross at the peak, as on a Norse longhouse, over the lowercase "a" the extension icon already had. The favicon (SVG, ICO and an Apple touch icon) and the extension icon put it on the accent colour; the header puts it, without its tile, beside the wordmark in the theme's chrome accent.
Put every page on one grid, with places as tabs and an account menu
Pages shared no width, so the column's edge moved from page to page. Now every page has a main column that takes what the sidebar leaves, under the wordmark; pages without a sidebar keep its track empty, so the column is the same width everywhere, and it's one full-width column on smaller screens. The form pages sit on panels like settings.
In the header, places (Home, Games) are tabs, the current one joined to the page; what's yours is in a menu behind your avatar. In the skeuomorphic theme the page tucks under the header's bottom edge and the footer's top: neither is stitched, and both are shaded.
Set sections apart with panels, space pages alike, and add themes
Blocks of content sit on a dim panel instead of between rules, and the header and footer are bands. Every page is spaced with the same rhythm tokens. A picker in the footer chooses a theme and light, dark or system mode, kept in cookies; alongside Paper there's a skeuomorphic theme.
Sign fetches as an instance actor, for servers that require it
GoToSocial, and Mastodon in secure mode, only answer signed requests, so following people there failed, and their Accept couldn't be verified at the shared inbox either. The server now has an instance actor, an Application at /users/instance-actor with keys in instance_keys, which signs lookups started here (lookupRemote, used instead of ctx.lookupObject) and fetches for the shared inbox.
Show on the extension's toolbar button whether a page is on the fediverse
Opt-in from the options page, since it needs permission to read every site. A small script reports each page's ActivityPub link; without one, a profile- or post-like address counts on sites that serve NodeInfo, which also covers Mastodon's web app changing the address as you click around. The badge shows a dot there, and with a second setting asks your server too, showing a tick if you follow the person or liked or boosted the post. The popup sets the badge from what it finds as well.
Look up every page in the extension popup, without a button
Single-page apps such as Mastodon's keep the ActivityPub link of the page they started on, so only looking up pages with one missed most posts. Post and profile lookups now run at once, and a page that's neither shows nothing instead of an error.
Add profile pictures, here and from other servers
People upload a picture in Settings. It's re-encoded like post images and cropped to a square of at most 400 px (animations stay animated), then sent to followers as the actor's icon. Remote actors' embedded icons are kept when they're cached (never fetched by URL) and shown from their own server. A shared Avatar component in @allheim/astro shows them next to posts, on profiles and in lists, with the name's first letter when there's no picture.
Let other servers send people here to interact with remote posts
Mastodon's "interact from your account" flow looks up the user's WebFinger for a subscribe template, and falls back to /authorize_interaction, which 404ed. WebFinger now advertises /authorize_interaction?uri={uri}, and the app serves it: the post with Like, Boost and Reply, or the account with Follow, after logging in.
Rename the app to apps/web, and drop the separate games app
The web app has posts and games, so apps/microblog is now apps/web (@allheim/app-web); the microblog module keeps its name. apps/games, the games on their own, is gone: its two-server chess test runs on the web app, at /games. The Dockerfile, scripts, docs and code tour follow the new path.
Send game posts as private mentions by default
Game posts were public, so everyone following both players saw every move in their home feed. They're now addressed to the opponent alone, which Mastodon shows as a private mention and answers privately by default. boardgames({ visibility }) takes "unlisted" to let followers follow along, or "public".
Each post keeps the visibility it was sent with (earlier posts were public), and is always served that way: a private post's Note only to requests signed from its addressee's server, and never in the outbox. Game pages stay public.
The e2e tests read game posts from each server's database now that the outbox doesn't list them, and check a private post's Note isn't served.
Show the starting board to a remote player who moves first
When someone elsewhere accepts a challenge from here and has the first move, they're now answered with "Game on! You play white: your move." and a picture of the board from their side. When the player here starts, their first move shows the board, as before. "Game on!" is no command, so another allheim server reads it as chat.
The microblog's e2e test now has alice play black, so bob's server gets that post, and checks it answers nothing.
Play games in the microblog, at /games
The microblog app adds the boardgames module with chess, so people play under the handle they post with: /games lists their games with a form to challenge someone, /games/{id} shows the board, and profiles link to a challenge.
Both modules serve Notes, and Fedify has one object dispatcher per type, so the service failed on its first request. Game posts are now served by a route of the module's own, which answers ActivityPub clients with the Note and sends browsers to the game, at the same URIs as before. Core now refuses two modules serving the same type at startup, naming both.
The microblog's e2e test has alice challenge bob and play a move each.
Add board games, with chess and a games app
A new module, @allheim/boardgames, plays turn-based two-player games between people here and anywhere on the fediverse. It handles challenges, accepting, declining and withdrawing them, turns, draw offers and resignations, from the web or from replies. Games plug in through defineGame, which only knows a game's rules and how to draw its board; @allheim/chess is the first, wrapping chess.js, with Cburnett's pieces (BSD) drawn as paths so the board renders on servers without fonts.
Remote players need nothing but a fediverse account. A post mentioning someone here and naming a game is a challenge. The local player's posts reply in the game's thread with a PNG of the board, and replies starting with a move or with accept, decline, withdraw, resign or draw are played; anything else is chat. Our posts start with the words we read, so two allheim servers play each other, and error replies start with "Sorry!", which reads as chat, so they can't answer each other forever.
apps/games is a second example app built from core and the module, with game pages (click-to-move, or typed moves without JavaScript), profiles with each player's record, and the account and moderation pages.
The e2e helpers move to scripts/e2e-lib.ts, and scripts/e2e-games.ts has two games instances play a game of chess to checkmate.
Show who boosted a post
Local posts with boosts link to a boosts page at /users/{identifier}/posts/{id}/boosts, next to the likes page, with tabs between the two. Both are now one ReactionList component, backed by postReactors (was postLikers), which takes the reaction type. Incoming Announces of local posts cache the booster's profile like Likes do.
The follower pages' tab styles move to components.css as .tabs now that two pages use them.
Show who liked a post
Local posts with likes link to a likes page at /users/{identifier}/posts/{id}/likes, listing everyone who liked them, newest first, 50 per page, without accounts the viewer blocked or muted. Incoming Likes now cache the liker's profile when it isn't known yet, so remote likers show by name and handle.
The pager styles move to components.css now that two pages use them.
Add images to posts
Up to four images per post, each with alt text, optionally marked sensitive. Uploads are re-encoded with sharp (metadata stripped, scaled to at most 3840 px), kept in a media store under MEDIA_PATH and served at /media/{key}. They federate as Document attachments with the sensitive flag. Attachments of remote posts are read when embedded, stored as links and shown from their own server, and sensitive media is hidden until clicked.
sharp is kept out of the server bundle so it can find its native binary, so apps need it as a dependency; request bodies are capped at 48 MB.
Plan client APIs for Mastodon apps and other fediverse software
Add followers and following pages
/@username/followers and /@username/following list the remote accounts, 50 per page like the ActivityPub collections; following leaves out pending follows. The counts on the profile and home sidebar link to them, and browsers opening the collection URIs are redirected there.
Make the server deployable on Coolify
- A Dockerfile that builds the server, keeps only production dependencies, runs as an unprivileged user with the database in /data, and includes the allheim CLI. tini runs as init, since Node as PID 1 ignores SIGTERM and every redeploy would otherwise end in SIGKILL.
- A docker-compose.yaml for Coolify's compose build pack, which declares the data volume (every user's private keys) and trusts Coolify's proxy for client addresses.
- astro.config.mjs fills security.allowedDomains from FEDERATION_ORIGIN at build time. Without it, Astro ignores the proxy's forwarded headers, so logins fail the origin check and cookies lose Secure.
- README: deploying with Coolify, and FEDERATION_ORIGIN at build time.
Add a browser extension, with API tokens and a JSON API
The extension (apps/extension, Manifest V3 for Firefox and Chrome) shares the current page, and follows, likes, boosts and replies to fediverse profiles and posts on any site. It signs in with a personal API token, created and revoked in Settings and stored only as a hash.
The JSON API under /api/v1 accepts only those tokens, never the session cookie, so it can allow any origin. Posts looked up through it are checked like incoming ones and stored only once someone here reacts to them.
Ignore Claude Code worktrees
Split the design system out of the layout and check its token contract
- The app's CSS moves from Layout.astro's global style block into src/styles/: tokens.css (the theme, now with type and spacing scales, radius and shadow tokens), then base.css, components.css and utilities.css as cascade layers. Components' scoped styles are unlayered, so they override the app's element defaults without the old button:not(...) exclusions. The layout keeps only its own chrome, scoped.
- Shared classes (.card, .rows, .details, .who-name/.who-handle, .stats) replace patterns that pages each styled themselves; pages and module components use the scales instead of raw sizes.
- The token contract lives in @allheim/astro/design-tokens. The app's test checks tokens.css defines every token (colours again for dark mode), and the microblog module's test checks its components use only contract tokens.
- Fixes: .secondary never applied to buttons (the old button selector outranked it); the post's More menu ran off-screen on phones; inputs are 1rem so phones don't zoom on focus.
- README and the code tour describe the new layout of styles.
Fix a type error in the Update handler
Keep the database private, and document proxy settings
The database holds every actor's private keys but was created readable by anyone on the host. Create it and its directory for the owner only, and say how to fix older ones. Also document security.allowedDomains, without which session cookies lose Secure behind a TLS-terminating proxy.
Don't fetch a post's tags given only by URL
Any server could send a post with thousands of tag URLs, and working out whether it mentions someone here fetched each of them. Mentions are embedded in practice, so only embedded tags are looked at.
Bound collection cursors
A huge outbox cursor made every module read and build all its items only to return an empty page, cheaply and without signing in. Past the end the outbox now returns an empty page straight away, and cursors that aren't non-negative integers are refused in every collection.
Only store notes hosted on their author's server
A server could host a note attributed to someone elsewhere and boost it, passing it off as theirs, or wrap content from a blocked domain in a boost by an account that isn't blocked.
Match domain blocks on hosts written with a trailing dot
evil.example. is the same host as evil.example, but didn't match its block, so a blocked server could get through by using it in its URIs.
Rate-limit logins by the address the proxy saw, and per username
With TRUST_PROXY, clientIp took the first X-Forwarded-For entry, which the client controls, so every limit could be dodged. Take the last one, which the proxy appends. Also cap guesses at one username from any number of addresses.
Keep redirects after forms on this site
safeLocalPath let through paths like /.//evil.example, which URL normalisation turns into //evil.example: an open redirect after login. redirectWith now collapses leading slashes. Adds vitest to @allheim/astro.
Only trust embedded notes from the sender's own server
Fedify trusts an embedded object on the same host as the activity's ID, which the sender chooses. A server could send an Announce or Create with an ID on someone else's host and a made-up note (and author) embedded: forged posts in timelines, squatted post URIs and a poisoned actor cache redirecting deliveries. Notes from any host but the signer's are now fetched from their own ID.
Only link remote and reported URLs that are on the web
Remote actors, notes and reports could carry javascript: URLs, which Astro renders into href attributes unchanged. Keep only http(s) URLs when storing them and when rendering links, via a new webUrl helper.
Give the microblog a warm, editorial look
A visual pass over the example app, in a warm editorial style with a sidebar layout on wide screens.
- Design tokens (CSS variables) for a paper-and-terracotta palette with a matching dark mode; Source Serif 4 (self-hosted via Fontsource) for content and a system sans-serif for chrome.
- Posts separated by rules instead of boxes, with one quiet meta line (relative time, Reply, Boost, Like with counts, a More menu for mute, block, report and delete) and liked/boosted states in colour.
- "In reply to @handle" names the author being answered; timestamps are relative, with the full date on hover.
- Home has compose and timeline beside a sidebar (profile card, follow box, following list); new welcome page for visitors; larger profile and post headers; narrow form pages; a tidier moderation page.
- Fixes: the page header's styles leaked into post headers, and the nav overflowed on mobile.
- Module components (Entry, Compose) now carry scoped styles built only on the app's tokens, so they follow any app's theme; the README's module guide lists the tokens and the code tour has a Design tokens step.
Add accounts and moderation: sign-up, invites, roles, blocks, reports
Milestone 5.
Accounts: - Web sign-up per allheim.config.ts `signup`: closed (default, CLI only), invite or open; NodeInfo reports open registrations. - Invites with optional use limits and expiry, consumed in the same transaction that creates the account. - Roles (user, moderator, admin): `allheim user:role`, and `user:create --role`; actionRoute can require a role. - Login rate limiting per IP and per IP+username, sign-ups per IP, stored in the database; TRUST_PROXY reads X-Forwarded-For.
Moderation: - Domain blocks, enforced in the inbox fan-out, by refusing to cache actors from blocked servers, and by removing what was cached. - Personal blocks (end follows both ways, send Block, Reject future follow requests, hide posts and boosts) and mutes (hide only). - Reports filed by users or received as Flag; the Flag handler never throws, so bad or repeated reports can't cause retry loops. - /admin for moderators (reports, blocked servers) and admins (invites); sign-up, report and settings pages; login moves to /api/login.
Each enforcement point has a test that fails when it is removed. The e2e test adds rate limiting, invite sign-up, reports and a domain block across two servers. README and code tour updated.
Work with other servers: timeline, mentions, replies, likes, boosts
Milestone 4.
Core: - sanitizeHtml cleans all incoming HTML (sanitize-html, Mastodon's post formatting only; links forced to nofollow noopener noreferrer). - @-mentions are resolved (locally or via WebFinger) and cached. - updateProfile saves name/bio and sends Update(Person) to followers; /settings page and injected /api/profile route. - Remote actors' Update refreshes the cache and Delete forgets them and everything referencing them, only when sent by the actor itself. - Fake remote servers (signed actors, WebFinger, document serving, delivery capture) moved to @allheim/core/testing for modules to use.
Microblog (tables added with the first real `pnpm db:generate`): - Incoming posts are stored when they concern someone here (followed author, reply to or mention of a local actor), sanitised; only authors can create, update or delete them. - Home timeline of own posts, followed accounts' posts and their boosts. - Mentions are tagged, linked and delivered to the people mentioned; replies go to the parent's author; post pages show replies. - Likes and boosts, sent and received, with Undo matched on activity and sender; boosts appear in the outbox. - Entry and reply-capable Compose components; PostItem removed.
Each security check has a test that fails when it is removed. The e2e test now covers posts in timelines, likes, boosts, replies, profile updates and deletes across two servers. README and code tour updated.
Move the data layer to Drizzle ORM
Tables are defined with Drizzle (pinned to 1.0.0-rc.4, whose node:sqlite driver keeps queries synchronous) and every query in core and the microblog goes through it. Row types now come from the definitions, replacing the `as unknown as` casts; `following.accepted` is a boolean.
- Core's tables in packages/core/src/tables.ts, the microblog's in its own src/tables.ts, via a shared `table` helper (camelCase keys, snake_case columns). Modules can reference core's tables.
- Schema changes: edit tables.ts and run `pnpm db:generate <name>`. drizzle-kit writes the SQL; `allheim migrations:embed` embeds it in src/generated-migrations.ts so it runs in the bundled server and the CLI alike. `withGenerated` appends it to the package's hand-written history, and our runner applies it as before (per module, with the foreign-key check). The pre-Drizzle migrations stay as history; drizzle-kit's baseline is only its starting snapshot.
- Schema-match tests (via @allheim/core/testing) compare the schema built from the definitions with the one built by the migrations: columns, defaults, foreign keys, indexes and checks.
The workflow was rehearsed end to end (a column addition and a table rebuild) against a copy of real data, then reverted. README and code tour updated.
Add the module API and move the microblog onto it
Milestone 3.
- defineModule: modules contribute migrations, actor kinds (actors.kind to an ActivityStreams type), object routes, inbox handlers, outbox items and NodeInfo counts.
- createAllheim runs every module's migrations and builds one Fedify Federation from core and the modules. Core is itself a module (person actors and the follow protocol).
- Inbox fan-out: one listener per activity type runs every module's handler in order; all run even if one throws, then the delivery fails so the sender retries.
- Outboxes merge all modules' items newest first.
- allheim.config.ts lists a service's modules; the CLI reads it for the new `allheim migrate` command.
- The Astro integration injects routes from packages: core's /logout, /api/follow and /api/unfollow, and the microblog's post endpoints. Endpoints find the service on Astro.locals.allheim.
- The microblog ships its endpoints and its PostItem/Compose components; the app keeps only config, pages and layout. The microblog package now runs astro check on its components.
- legacy_users is dropped once no table references it.
Tests cover migrations, duplicate names and kinds, custom actor kinds and objects, fan-out ordering and failure, outbox merging and paging, NodeInfo sums and the legacy cleanup. README gains a "Writing a module" guide; code tour updated.
Add migrations, split accounts from actors, cache remote actors
Milestone 2: the core data model.
- Versioned migrations per package (schema_migrations). Each runs in a transaction with foreign keys off, is checked with foreign_key_check before committing, and older code refuses a newer database. Existing databases adopt migration 1 unchanged.
- Accounts (logins) are separate from actors (fediverse identities). Actors are addressed by a stable ID in their URI (/users/{id}) and handles map to it via mapHandle, so actors can be renamed. New actors get UUIDs; existing ones keep their username as ID so known URIs stay valid. The old users table becomes legacy_users (passwords cleared) until modules stop referencing it.
- Remote actor cache: remote_actors holds inbox, shared inbox, handle and name once; followers and following reference it.
- Profiles move to /@username; actor URIs redirect browsers there, and WebFinger resolves profile URLs (mapAlias).
- Microblog posts belong to actors, via the module's own migrations.
Tests cover the runner (ordering, idempotence, rollback, foreign-key safety net, downgrade refusal), upgrading pre-migration data row by row, renames, and the remote cache. The e2e test discovers actors through WebFinger and follows by profile URL. README and code tour updated.
Split into a monorepo: core, Astro integration, microblog module
Restructure the single Astro app into a pnpm workspace so it can serve as a boilerplate for future ActivityPub services. No behaviour change.
- packages/core (@allheim/core): federation setup as a factory (createAllheimFederation), actors, keys, follows, accounts, sessions, SQLite schema, and an `allheim` CLI replacing the user scripts (with --password-stdin for automation). Framework-agnostic.
- packages/astro (@allheim/astro): Astro integration (env schema, Fedify's Vite options) on its own entry point so astro/config stays out of the server bundle, plus middleware and form-route helpers.
- modules/microblog (@allheim/microblog): posts, outbox source, Note dispatcher and HTML rendering.
- apps/microblog: the Astro app, wiring core and the module together.
Tests: - core: vitest suites driving the real Federation, including inbox tests with HTTP-signed requests from fake remote actors and a stubbed fetch standing in for remote servers. - microblog: post rendering and HTML escaping. - scripts/e2e.ts (`pnpm test:e2e`): two real instances federate over HTTP (follow, accept, post, delete, unfollow).
The Undo listener now ignores objects it can't fetch instead of failing. README, code tour and agent instructions updated for the new layout.
Add ActivityPub federation with Fedify
Turn the Astro starter into a small multi-user ActivityPub server:
- Fedify wired in through Astro middleware, with WebFinger, NodeInfo, Person actors (RSA + Ed25519 keys), and paged followers, following and outbox collections
- Inbox handling for Follow, Undo(Follow), Accept and Reject
- Posting (Create/Delete of Notes) and following/unfollowing remote accounts, delivered through Fedify's SQLite-backed queue
- Password login with scrypt hashes and hashed session tokens; users are created with `pnpm user:create`
- SQLite storage via node:sqlite, and the Node standalone adapter
- Federation settings declared in Astro's env schema
- README documentation and a CodeTour walkthrough
"Initial commit from Astro"