洪 民憙 (Hong Minhee) :nonbinary:'s avatar

洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

1,099 following1,872 followers

An intersectionalist, feminist, and socialist living in Seoul (UTC+09:00). @tokolovesme's spouse. Who's behind @fedify, @hollo, and @botkit. Write some free software in , , , & . They/them.

서울에 사는 交叉女性主義者이자 社會主義者. 金剛兔(@tokolovesme)의 配偶者. @fedify, @hollo, @botkit 메인테이너. , , , 等으로 自由 소프트웨어 만듦.

()

Pinned

@hongminhee@hollo.social

Hello! I'm Hong Minhee (洪 民憙), an open source software engineer in my late 30s, living in Seoul, Korea. I'm bisexual and non-binary (they/them), and an enthusiastic advocate of free/open source software and the fediverse.

I work full-time on @fedify, an ActivityPub server framework in TypeScript, funded by @sovtechfund. I'm also the creator of @hollo, a single-user ActivityPub microblog; @botkit, an ActivityPub bot framework; Hackers' Pub, a fediverse platform for software developers; and LogTape, a logging library for JavaScript and TypeScript.

I have a long interest in East Asian languages (CJK) and Unicode. I post mostly in English here, though occasionally in Japanese or in mixed-script Korean (國漢文混用體), a traditional writing style that interleaves Chinese characters with the native Korean alphabet. Wanting to write in that style was actually one of the reasons I joined the fediverse. Feel free to talk to me in English, Korean, Japanese, or even Literary Chinese!

en.wikipedia.org

Korean mixed script - Wikipedia

Pinned

はじめまして!ソウル在住の30代後半のオープンソースソフトウェアエンジニア、洪 民憙ホン・ミンヒと申します。バイセクシュアル(bisexual)・ノンバイナリー(non-binary)で、自由・オープンソースソフトウェア(F/OSS)とフェディバース(fediverse)の熱烈な支持者です。

STF(@sovtechfund)の支援を受け、TypeScript用ActivityPubサーバーフレームワーク「@fedify」の開発に専念しています。他にも、おひとり様向けのActivityPubマイクロブログ「@hollo」、ActivityPubボットフレームワーク「@botkit」、ソフトウェア開発者向けフェディバースプラットフォームHackers' Pub、JavaScript・TypeScript用ロギングライブラリLogTapeなどの制作者でもあります。

東アジア言語(いわゆるCJK)とUnicodeにも興味があります。このアカウントでは主に英語で投稿していますが、時々日本語や国漢文混用体(漢字ハングル混じり文)の韓国語でも書いています。実はこの文体で書きたくてフェディバースを始めた、という経緯もあります。日本語、英語、韓国語、漢文でも気軽に話しかけてください!

speakerdeck.com

国漢文混用体からHolloまで

本発表では、韓国語の「国漢文混用体」(漢字ハングル混じり文)を自分のフェディバース投稿に実装したいという小さな目標から始まった旅路を共有します。 この目標を達成するために、ActivityPubのJSON-LDの複雑さやHTTP Signatures、WebFingerなどの仕様を理解する必要性に…

Pinned

安寧(안녕)하세요! 저는 서울에 살고 있는 30() 後半(후반)의 오픈 소스 소프트웨어 엔지니어 洪民憙(홍민희)입니다. 兩性愛者(양성애자)(bisexual)이자 논바이너리(non-binary)이며, 自由(자유)·오픈 소스 소프트웨어(F/OSS)와 聯合宇宙(연합우주)(fediverse)의 熱烈(열렬)支持者(지지자)이기도 합니다.

STF(@sovtechfund)의 支援(지원)을 받아 TypeScript() ActivityPub 서버 프레임워크 @fedify 開發(개발)專業(전업)으로 ()하고 있습니다. 그 ()에도 싱글 유저() ActivityPub 마이크로블로그 @hollo, ActivityPub 봇 프레임워크 @botkit, 소프트웨어 開發者(개발자)를 위한 聯合宇宙(연합우주) 플랫폼 Hackers' Pub, JavaScript·TypeScript() 로깅 라이브러리 LogTape ()製作者(제작자)이기도 합니다.

()아시아 言語(언어)(이른바 CJK)와 Unicode에도 關心(관심)이 많습니다. 이 計定(계정)에서는 ()英語(영어)로 포스팅하지만, 때때로 日本語(일본어)國漢文混用體(국한문 혼용체) 韓國語(한국어)로도 씁니다. 聯合宇宙(연합우주)에 오게 된 動機(동기) () 하나가 바로 國漢文混用體(국한문 혼용체)로 글을 쓰고 싶었기 때문이기도 하고요. 韓國語(한국어), 英語(영어), 日本語(일본어), 아니면 漢文(한문)으로도 말을 걸어주세요!

logtape.org

LogTape

Unobtrusive logging library with zero dependencies—library-first design for Deno, Node.js, Bun, browsers, and edge functions

@wakest@hackers.pub

the new hackers' pub is looking really nice. seeing that botkit now supports FEP-044f I wonder if that means full QP support will soon arrive here and Hollo

hackers.pub

BotKit 0.5.0: A new design language, multi-bot instances, and consent-respecting quotes

BotKit 0.5.0 introduces significant updates to the framework, headlined by the ability to host multiple bots from a single process through a new multi-bot instance architecture. This release brings a visual overhaul to both the documentation site and the automatically generated bot pages, replacing external dependencies with a self-contained, customizable design system that improves visitor privacy. A major technical addition is the support for FEP-044f, which implements a consent-based handshake for quoting messages to ensure better compatibility with Mastodon’s quoting mechanisms. Developers can now utilize a new Redis-backed repository for shared production environments, complementing existing storage options with robust concurrency handling through short-lived locks. The update includes breaking changes for custom repository implementations and shifts bot sessions to a read-only model to support lazy resolution of bot identities. These enhancements provide developers with greater flexibility in deploying complex bot fleets while maintaining high standards for privacy and Fediverse interoperability.

@botkit@hackers.pub

We're pleased to announce BotKit 0.5.0. This release changes how both the docs site and every bot's own pages look, and it stops limiting a server to one bot: a single process can now host a whole fleet of them. Quoting, and being quoted, goes through a consent step under FEP-044f, and a Redis-backed repository joins the SQLite and PostgreSQL ones for shared production storage. A few of these changes are breaking; see below for what to check before you upgrade.

A new look for botkit.fedify.dev

Until this release, botkit.fedify.dev read like a stock VitePress site: a workable but generic shell wrapped around the docs, with none of BotKit's own personality showing through.

The theme and the landing page are both new. The palette now matches the real logo greens instead of a generic default, and headlines are set in Space Grotesk, paired with Inter for everything else; the display font is self-hosted, so no visitor's IP is handed to a font CDN. The new landing page frames BotKit's dinosaur mascot inside its signature unassembled-model-kit sprue frame, then leads with a tabbed installer for Deno, npm, pnpm, and Yarn and a one-file bot example before getting into what BotKit actually does: messages, events, multi-bot instances, and the pages it builds for a bot without any extra work from you.

The redesigned botkit.fedify.dev homepage: a framed dinosaur mascot in a model-kit sprue, a one-file install snippet, and a rotating code sample.

The deployment guides grew alongside the new landing page: they were split apart and fleshed out, and a new Cloudflare Workers guide joins the existing Deno Deploy, Docker, and self-hosting guides.

A new look for your bot's pages

The pages BotKit serves for your bot (its profile, its posts, its follower list) got the same attention, in the opposite direction. Until now they were built on Pico CSS pulled from an external CDN: a fine default, but a generic one that made every BotKit-hosted bot look like a demo of the same template, and that quietly sent every visitor's browser off to fetch a stylesheet from someone else's server.

That's gone. Bot pages now use a self-contained design system, bundled with the package and served from BotKit's own content-addressed, cache-forever path: no external CDN, no build step, on either Deno or Node.js. The whole look is driven by a single accent color you choose (twenty names, the same legend Pico CSS uses, so your existing choice of color still works), tinting links, the follow button, and small highlights, while everything else stays quiet so your bot's name, avatar, and posts are what a visitor actually notices. A new PagesOptions.theme option ("auto", "light", or "dark") controls the color scheme independently of the accent. A repost is now marked and attributed to its original author instead of blending into the bot's own timeline.

A bot's redesigned profile page: a banner and avatar, bio with a link and mention, custom properties, follower and post counts, and a follow button, all tinted in the bot's chosen accent color.

If you want to go further than the accent color allows, PagesOptions.css still lets you inject custom CSS on top of BotKit's own stylesheet.

Multi-bot instances

Until this release, a BotKit process could only ever be one bot. Running a second bot meant standing up a whole second server, even when the two bots could easily have shared the same infrastructure. That limitation, raised in #16, is gone: the new createInstance() function creates an Instance that owns the shared plumbing (the key–value store, the message queue, the repository, and HTTP handling), and any number of bots can live on it side by side, each with its own actor identity and event handlers.

For a fixed, known set of bots, Instance.createBot() takes an identifier and a profile:

import { createInstance, text } from "@fedify/botkit";
import { MemoryKvStore } from "@fedify/fedify";

const instance = createInstance<void>({ kv: new MemoryKvStore() });

const greetBot = instance.createBot("greet", {
  username: "greetbot",
  name: "Greeting Bot",
});

greetBot.onFollow = async (session, followRequest) => {
  await followRequest.accept();
  await session.publish(text`Welcome, ${followRequest.follower}!`);
};

For a family of bots resolved on demand (one per region, one per customer, thousands of them backed by a database), pass a dispatcher function instead of a fixed identifier, and BotKit resolves and federates each one lazily:

const weatherBots = instance.createBot(async (ctx, identifier) => {
  if (!identifier.startsWith("weather_")) return null;
  const region = await db.getRegion(identifier.slice("weather_".length));
  if (region == null) return null;
  return { username: identifier, name: `${region.name} Weather Bot` };
});

weatherBots.onMention = async (session, message) => {
  const code = session.bot.identifier.slice("weather_".length);
  await message.reply(text`Current weather: ${await db.getWeather(code)}`);
};

Incoming activities are routed only to the bots they actually concern: the followed bot, the owner of a liked or replied-to message, mentioned bots, and bots followed by the sender. Multi-bot instances also serve a list of hosted bots at the web root, with each bot's own pages moving to /@{username}; a reserved instance actor signs shared-inbox requests when there's no single bot whose key obviously should.

None of this touches single-bot deployments: createBot() keeps working exactly as it always has, with the bot's pages staying at the web root and its data migrated to the new storage layout automatically on startup. If you maintain a custom Repository implementation, though, this is a breaking change worth planning for: every method now takes the owning bot's identifier as its first parameter, Session.bot is a read-only ReadonlyBot instead of a mutable Bot, and local object URIs carry the owning bot's identifier (old-format URIs are still recognized and permanently redirected, so links other servers stored keep working). The full picture, including how to move an existing single-bot deployment onto a multi-bot instance later, is in the new Instance concept document.

Thanks to @moreal, whose early work-in-progress explorations of this problem surfaced its two hardest design questions (mapping usernames to identifiers for dynamic bots, and routing object URIs that used to carry no owner information) well before this implementation settled on its final shape.

BotKit has supported quoting since 0.2.0, but only in the Misskey family's style: a quoteUrl property and a Link tag, sent without ever asking the quoted author. Mastodon 4.4 and 4.5 do things differently: they verify quotes through FEP-044f's consent handshake before showing them as quotes at all. Without that handshake, a BotKit bot's quotes never rendered as quotes on Mastodon, and quotes of a BotKit bot showed up as unverifiable.

BotKit now handles the FEP-044f handshake in both directions. When you quote a message, it sets the FEP-044f quote property and sends a QuoteRequest to the quoted author, alongside the Create it already sent. Publishing stays non-blocking: the post goes out immediately, and the quote is upgraded once (or if) approval arrives:

const message = await session.publish(text`This message quotes another one.`, {
  quoteTarget: quoted,
});
console.log(message.quoteApprovalState); // "pending"

On the receiving side, a new quotePolicy option (on createBot(), and per message on Session.publish()) controls how your bot answers incoming quote requests: automatically for everyone ("public", the default), automatically for followers only, never ("nobody"), or held for manual review through the new Bot.onQuoteRequest event:

bot.onQuoteRequest = async (session, request) => {
  if (request.state === "pending") await request.accept();
};

Bot.onQuoteAccepted, Bot.onQuoteRejected, and Bot.onQuoteRevoked cover what happens next for quotes your own bot sent, Message.quoteApproved reports whether an incoming quote carries a valid authorization stamp, and AuthorizedMessage.unauthorizeQuote() lets you revoke one you previously granted. The legacy quoteUrl and Misskey-style tags are still sent alongside the new property, so nothing about quoting on Misskey and its relatives changes. The full design is spread across #27 through #33.

A Redis repository (@fedify/botkit-redis)

BotKit has had a SQLite repository since 0.3.0 and a PostgreSQL one since 0.4.0, but neither is the natural fit for a bot that runs as several worker processes sharing one store, which is exactly the shape a Redis-backed deployment usually takes. The new @fedify/botkit-redis package fills that gap with RedisRepository, built directly on Redis strings, sets, and sorted sets rather than going through a generic key–value abstraction:

import { createBot, MemoryKvStore } from "@fedify/botkit";
import { RedisRepository } from "@fedify/botkit-redis";

const bot = createBot({
  username: "mybot",
  kv: new MemoryKvStore(),
  repository: new RedisRepository({ url: "redis://localhost:6379/0" }),
});

Because several workers can share one Redis instance, the read-modify-write paths that matter most under concurrency (message updates, follower bookkeeping, quote authorization indexes) are protected by short-lived locks that get renewed while a slow update is still running, rather than by assuming only one process ever touches the data at a time. The package supports both a connection URL it manages itself and an existing node-redis client you inject and keep control of, and it's available for both Deno and Node.js. #12 and #35 cover the rest of it.

Smaller improvements

The npm package's TypeScript declaration files no longer accidentally include the runtime Temporal polyfill code, which had been leaking into consumers' .d.ts output. Fedify was upgraded to 2.3.1, Hono to 4.12.27, and LogTape to 2.2.3.


As always, the full list of changes is in CHANGES.md, and every API mentioned above is documented at botkit.fedify.dev. Thank you to everyone who filed issues, opened discussions, and tried BotKit out.

If you build something with BotKit, run into a rough edge, or just want to talk through an idea before opening an issue, GitHub Discussions is the place for exactly that. For something closer to real time, BotKit's chat now lives on Matrix at #fedify:matrix.org. Drop in and say hello.

matrix.to

You're invited to talk on Matrix

You're invited to talk on Matrix

@hongminhee@hollo.social

中國(중국)·日本(일본)·臺灣(타이완) ()非韓國語(비한국어) 話者(화자)들을 ()質問(질문): 如斯(여사)하게 作成(작성)國漢文混用體(국한문 혼용체) 韓國語(한국어) 文章(문장)理解(이해) 可能(가능)한가요?

  • 大體로 理解 可能하다5 (45%)
  • 어느 程度 理解 可能하다5 (45%)
  • 理解 不可能하다1 (9%)
@botkit@hackers.pub

If you use BotKit, update to a patched release now. CVE-2026-62857 affects Fedify's NodeInfo client, and BotKit includes the affected Fedify versions as a dependency.

Fedify can look up a remote server's NodeInfo document to learn what software it runs. The lookup first fetches the server's /.well-known/nodeinfo document, then follows the NodeInfo document URL advertised in that response. The vulnerable paths are getNodeInfo() and the Context.lookupNodeInfo() method that wraps it: affected versions sent both requests without checking that their destinations were on the public internet. Because the second URL comes from the remote server's response, an attacker who controls a server being looked up could point it at a loopback address, a link-local cloud metadata endpoint, an RFC 1918 host, or a data: URL. Depending on the deployment environment and network routing, this could cause a BotKit application that looks up NodeInfo to fetch non-public network resources and return their contents to the application.

The fix routes both requests through Fedify's public-address validation. It checks every request before sending it, including each redirect hop, limits the number of redirects, refuses redirects that cross protocols, and rejects non-HTTP(S) URLs. Servers are exposed only if they look up remote NodeInfo, but such lookups are commonly used for peer discovery and instance metadata.

BotKit 0.4.x versions through 0.4.4 and BotKit 0.5.0 are affected. Patched releases are 0.4.5 and 0.5.1. BotKit 0.4.5 uses Fedify 2.1.19, and BotKit 0.5.1 uses Fedify 2.3.3.

For BotKit 0.5.x, update @fedify/botkit:

npm  update  @fedify/botkit
yarn upgrade @fedify/botkit
pnpm update  @fedify/botkit
bun  update  @fedify/botkit
deno update  @fedify/botkit

For BotKit 0.4.x, update @fedify/botkit:

npm  update  @fedify/botkit@0.4.5
yarn upgrade @fedify/botkit@0.4.5
pnpm update  @fedify/botkit@0.4.5
bun  update  @fedify/botkit@0.4.5
deno update  @fedify/botkit@0.4.5

After updating, redeploy. The GitHub Security Advisory is GHSA-hqph-j65v-8cq5, and the CVE ID is CVE-2026-62857. See also Fedify's own announcement.

Thanks to @rvzsec and @manus-use for the report and responsible disclosure.

If anything is unclear, feel free to ask on GitHub Discussions or Matrix.

matrix.to

You're invited to talk on Matrix

You're invited to talk on Matrix

@hollo@hollo.social

Hollo security updates: 0.8.9 and 0.9.9

If you run Hollo, update to a patched release now. CVE-2026-62857 affects Fedify's NodeInfo client, which Hollo uses to identify the software running on remote ActivityPub servers.

A NodeInfo lookup starts by fetching a remote server's /.well-known/nodeinfo document, then follows the NodeInfo document URL advertised in that response. The vulnerable getNodeInfo() path fetched both URLs without validating that they resolved to public network destinations. Because the second URL comes directly from a response controlled by the remote server, it could point to a loopback address, a link-local cloud metadata endpoint, an RFC 1918 private address, or even a data: URL.

An attacker who controls a remote server that Hollo discovers could therefore make the Hollo instance initiate requests to non-public network destinations, depending on the deployment environment and network routing.

The fix applies Fedify's public-address validation to both NodeInfo requests and every redirect hop. It also caps redirects, refuses cross-protocol redirects, and rejects non-HTTP(S) URLs. As a result, NodeInfo lookups for private or intranet addresses are now refused.

For full technical details of the underlying vulnerability, see the Fedify security advisory and the Fedify security announcement.

All Hollo versions in the supported 0.8.x and 0.9.x release lines up to and including 0.8.8 and 0.9.8 are affected. Patched releases are 0.8.9 for the 0.8.x series and 0.9.9 for the 0.9.x series.

Hollo 0.7.x is also affected. It and earlier release lines are no longer supported under the Hollo security policy. Upgrade to a supported release series rather than remaining on an older version.

For 0.8.x deployments, update to 0.8.9:

docker pull ghcr.io/fedify-dev/hollo:0.8.9

For 0.9.x deployments, update to 0.9.9:

docker pull ghcr.io/fedify-dev/hollo:0.9.9

After pulling the new image, restart your Hollo container. If you deploy from source, pull the corresponding release tag and restart.

Thanks to @rvzsec and @manus-use for the report and responsible disclosure to the Fedify project.

If anything is unclear, ask below.

github.com

manus-use - Overview

Cybersecurity Researcher | Sharing practical InfoSec knowledge - manus-use

@fedify@hackers.pub

If you use Fedify, update to a patched release now. CVE-2026-62857 affects Fedify's NodeInfo client. An attacker who runs any instance your server looks up could cause that server to fetch non-public network destinations and return their contents to your application, depending on the deployment environment and network routing.

Fedify can look up a remote instance's NodeInfo document to learn what software it runs. The lookup happens in two steps: it fetches the instance's /.well-known/nodeinfo document, then follows the NodeInfo document URL that response advertises. The vulnerable path is getNodeInfo(), along with the Context.lookupNodeInfo() method that wraps it: affected versions sent both requests without validating the destination against public-network expectations. Because that second URL comes straight out of the remote server's response body, the instance being looked up fully controls it, and could point it at a loopback address, a link-local metadata endpoint, an RFC 1918 host, or a data: URL. Servers are exposed only if they look up NodeInfo, but that lookup is routine for peer discovery and instance metadata.

The fix routes both requests through the same public-address validation Fedify already applied to WebFinger lookups and remote document loading. Every request is now checked before it is sent, including each redirect hop, so a public URL cannot bounce a request to an internal address. Redirects are followed with a cap and are refused if they cross protocols, and non-HTTP(S) URLs such as data: are rejected outright.

These are patch releases, so they tighten behavior without adding new API. If you deliberately look up NodeInfo on a private or intranet address, such as in a closed federation or a test environment, these releases will now refuse it. An allowPrivateAddress opt-out is coming in 2.4.0.

Current patched releases are 1.9.13, 1.10.12, 2.0.22, 2.1.18, 2.2.7, and 2.3.2. The GitHub Security Advisory is GHSA-hqph-j65v-8cq5, and the CVE ID is CVE-2026-62857.

Update @fedify/fedify:

npm  update  @fedify/fedify
yarn upgrade @fedify/fedify
pnpm update  @fedify/fedify
bun  update  @fedify/fedify
deno update  @fedify/fedify

After updating, redeploy. If you run other Fedify-based servers, update those too.

Thanks to @rvzsec and @manus-use for the report and responsible disclosure.

If anything is unclear, ask below.

github.com

manus-use - Overview

Cybersecurity Researcher | Sharing practical InfoSec knowledge - manus-use

@jnkrtech@treehouse.systems · Reply to skategoat 🐐 🇵🇸

@felipe my take is that the problems and inconsistencies in the YAML spec are bad, but are also things which should be trivially solved by a linter. I think the real reason people mostly don’t like YAML is that it makes it cheap and easy to accidentally build a programming language, and any programming language created by accident is almost certainly going to be shit.

So I guess I’m YAML-neutral. I don’t like TOML or JSON much better.

@cheeaun@mastodon.social

👀 new 3rd-party Mastodon/fediverse client

🤔 can't find the English translation. Even the browser's built-in translator is failing maybe due to its usage of Flutter.

@d@mastodon.demo2.jp

私が開発しているMastodonクライアントアプリの紹介ページを作成しました!
これまでごく一部の人に試してもらっていましたが、より広く公開しようと思います。
現在まだテスト版ですが、使っていただけると嬉しいです。(ぜひ拡散おねがいします)

demo2.jp/kurage/

demo2.jp

Kurage — Fediverse の海を、自由に漂う。

マルチプラットフォーム対応の Mastodon クライアント。複数アカウントの統合タイムライン・ストリーミング・豊富な投稿オプション。

@d@mastodon.demo2.jp

私が開発しているMastodonクライアントアプリの紹介ページを作成しました!
これまでごく一部の人に試してもらっていましたが、より広く公開しようと思います。
現在まだテスト版ですが、使っていただけると嬉しいです。(ぜひ拡散おねがいします)

demo2.jp/kurage/

demo2.jp

Kurage — Fediverse の海を、自由に漂う。

マルチプラットフォーム対応の Mastodon クライアント。複数アカウントの統合タイムライン・ストリーミング・豊富な投稿オプション。

ブログ『洪民憙雑記』をPHPからAstro + Netlifyに移行して、ついでにFedifyでActivityPubに対応させた話を文章にまとめた。

https://writings.hongminhee.org/2026/07/fedified-blog/

writings.hongminhee.org

I added ActivityPub to this blog

I built this blog with Jikji, a static site generator I wrote myself, almost five years ago. Back then I barely knew TypeScript or modern web tooling, and I'd…

@hongminhee@writings.hongminhee.org
I built this blog with Jikji, a static site generator I wrote myself, almost five years ago. Back then I barely knew TypeScript or modern web tooling, and I'd…

I added ActivityPub to this blog

I built this blog with Jikji, a static site generator I wrote myself, almost five years ago. Back then I barely knew TypeScript or modern web tooling, and I'd never implemented ActivityPub. TypeScript and modern web tooling are second nature to me now, and ActivityPub has become central to my work. I maintain Fedify, for whatever that's worth, and it bothered me that my own blog wasn't federated. So I fixed that.

The old stack: Jikji and PHP

This blog used to run on Jikji, a static site generator I wrote myself in Deno. Calling it a static site generator is a bit of a stretch, though. Like old Movable Type installations, it didn't just produce HTML; it generated a bit of PHP too. That PHP existed almost entirely for HTTP content negotiation: it read the browser's Accept-Language header and chose among Korean mixed script, hangul-only Korean, English, and Japanese. That's all it did.

I first considered adding a thin ActivityPub implementation directly in PHP, since I was already using it. But I wasn't really writing that PHP by hand; Jikji generated it for me, and I had no interest in hand-coding PHP myself. Federating meant delivering a Create(Article) activity to followers whenever a new post went up, which meant I'd need something like a message queue. Bolting a message queue onto Jikji's generated PHP felt, to me at least, like more complexity than it was worth maintaining. And honestly, with Fedify already around, I had no desire to implement ActivityPub from scratch again.

So I ripped out PHP entirely and decided to bring in Fedify instead.

The new stack: Astro and Netlify

The first decision was to drop Jikji and PHP for Astro, a JavaScript framework built for static-content-heavy sites. I chose Astro largely because it already had a @fedify/astro integration.

I reused as much of the existing CSS and HTML as I could. I'm happy with the current design, and redoing it alongside everything else felt like scope creep waiting to happen. Permalinks stayed exactly as they were. I wanted to replace the stack underneath without visitors noticing anything had changed at all.

For hosting, I went back and forth between Cloudflare Workers and Netlify, and settled on Netlify partly because Fedify had never run there before, and this seemed like a good excuse to add that support. I've hosted static sites on Netlify plenty of times, but this was my first time pairing it with edge functions. The idea of a mostly static site with a few dynamic slices reminded me of the late-nineties web, when a site was static HTML except for whatever lived in /cgi-bin/.

Publishing used to mean committing a Markdown file to Git, pushing, letting GitHub Actions build the static site, and deploying it over SFTP. Now GitHub Actions is out of the build pipeline entirely, since Netlify builds the site itself. It ended up simpler overall.

I'm happy with Astro, and the migration went smoothly. It beats Jikji, which I'd barely touched since building it five years ago. Jikji is now archived; there's no reason left for me to keep maintaining it.

Fitting Fedify into Astro

Updating @fedify/astro

Once I actually tried to add Fedify to Astro, I ran into a problem: @fedify/astro didn't support Astro 7, the current version. The Astro APIs it relied on hadn't changed much internally, but the package's declared compatibility range, and its tests, only went up to Astro 5. So before I could federate the blog, I had to fix @fedify/astro first.

That meant more than widening a version range. The existing tests built a fake Astro context and called the middleware directly, which couldn't catch problems with Vite's SSR configuration, compatibility across adapters, or request routing on a built server. So I wrote new compatibility tests that pack @fedify/astro for real, install it into a small Astro app, build and start the app, and send real HTTP requests to it.

Those tests check, across Astro 5, 6, and 7, that HTML requests reach Astro's pages, that ActivityPub and WebFinger requests are handled by Fedify, and that Astro's 404 Not Found still applies to everything else. For Astro 7 specifically, I also run the tests against Deno and Bun, not just the Node.js adapter.

That work has already been merged upstream and will ship in Fedify 2.4.0.

Static pages, dynamic endpoints

The Astro project as a whole builds with server output, but the existing blog pages are still prerendered, same as before. WebFinger, the actor, the inbox and outbox, the followers collection, and ActivityPub objects are the exceptions: Fedify handles those dynamically, per request. The middleware @fedify/astro provides looks at a request's URL and Accept header and only intercepts what Fedify is meant to handle. The same URL can return the existing Astro page for an HTML request and a Fedify-built object for an ActivityPub one.

What visitors see is still, for all practical purposes, a static site. Nearly all the new dynamic surface lives somewhere only other fediverse servers ever touch. That's the CGI comparison again.

Person and Article

Adding ActivityPub also meant deciding what counts as an actor here, and what counts as an object. I gave the blog's actor a Person type. Publishing itself is automated, but the actor represents me, the person writing these posts, not a piece of software or a service. So the handle is @hongminhee@writings.hongminhee.org, and the actor's web URL points at the blog.

Each post gets an Article. It has a title and a body, and it lives at its own permalink as a long-form document, which fits Article better than Note. Most major ActivityPub implementations support Article these days, Mastodon included. Human-facing permalinks stayed put; ActivityPub objects got their own URIs instead, shaped like /ap/articles/{year}/{month}/{slug}. Article's url points back at the original permalink, so the object's identity and the web page people actually read stay separate.

Multiple languages took more thought. Representing each language as its own Article would scatter likes and shares for the same post across several objects. So I merged the Korean mixed script, hangul-only Korean, English, and Japanese versions under a single Article, all sharing one permalink. Title, summary, and body each carry language-tagged values for every version, which serialize to JSON-LD as nameMap, summaryMap, and contentMap. For implementations that don't handle per-language values, name, summary, and content also carry a default: English if there's an English version, Korean mixed script otherwise. Each language's HTML page also gets a Link on Article's url, tagged with hreflang.

That way, a receiving server that understands multiple languages can pick a title and body matching the reader's language, and one that doesn't can still fall back to the default. In practice, though, I know of hardly any ActivityPub implementation that renders these multilingual values properly yet. There's an open issue for it on Mastodon's tracker, and a similar proposal on Hackers' Pub's, but neither has a timeline. Some of that is probably a UI design problem as much as anything else.

Running Fedify on Netlify

Unlike serving plain static files, an ActivityPub server needs some state that outlives any single deploy. The actor's signing key can't rotate on every deploy. The followers list can't disappear on the next one either. Both live in Netlify Database.

Incoming and outgoing activities go through a message queue built on Async Workloads. Delivery can be slow or fail outright depending on the receiving server, so it can't all happen inside the function handling the HTTP request. Queuing it separates accepting a request from actually delivering it, and failed deliveries can be retried later. Fedify already abstracts this, with pluggable backend adapters, but there wasn't yet an adapter for Netlify's Async Workloads. So I wrote the @fedify/netlify package, which uses Async Workloads as the queue and keeps delivery-order state in Netlify Database.

Announcing new posts to the fediverse turned out to be a separate problem. A static site finishing its build doesn't tell a running ActivityPub server anything about which posts changed. So on every successful production deploy, I diff the current post list against the previous deploy's. New posts get a Create(Article); edited ones, whether the content or just the timestamp changed, get an Update(Article); removed ones get a Delete(Article). All of it goes out to followers. Retries reuse the same activity ID for the same change, and deploy ordering is checked so that an older deploy syncing late can't undo a newer one.

Netlify's deploy previews and branch deploys have federation turned off entirely. Otherwise every preview would spin up an actor claiming to be this same blog, and a test deploy could end up sending activities to real followers. Locally, I develop against an in-memory store and queue; production is the only place using the persistent database and queue.

Fedify now runs on Netlify Functions, alongside Deno Deploy and Cloudflare Workers, on top of its usual support for Node.js, Deno, and Bun.

Wrapping up

None of this gives the blog a timeline, a reply box, or any other social feature. Writing and reading still work the way they always did, and the permalinks and design are basically untouched. What changed is that the blog, and every post on it, now has a name and address the fediverse understands. Follow @hongminhee@writings.hongminhee.org to get new posts, or look up a post's ActivityPub object URI to find the original.

I've maintained Fedify long enough to show other developers how to implement ActivityPub, and I dogfooded it plenty while building Hollo and Hackers' Pub. But this was the first time I'd added it to a site that was already live, and static at that. Along the way I got a compatibility test suite for the Astro integration, Netlify support, and a handful of deployment and operational problems that no amount of reading docs or unit tests would have surfaced. It turns out Fedify isn't just for building new social networks from scratch; it works just as well for bringing an existing site into the fediverse without changing how it looks.

hackers.pub

Hackers' Pub: Local timeline

@hongminhee@hollo.social

Wrote up the story of moving my blog, Hong Minhee on Things (洪民憙雜記), off PHP and onto Astro + Netlify, and adding ActivityPub support along the way with Fedify.

https://writings.hongminhee.org/2026/07/fedified-blog/

writings.hongminhee.org

I added ActivityPub to this blog

I built this blog with Jikji, a static site generator I wrote myself, almost five years ago. Back then I barely knew TypeScript or modern web tooling, and I'd…

@hongminhee@writings.hongminhee.org
I built this blog with Jikji, a static site generator I wrote myself, almost five years ago. Back then I barely knew TypeScript or modern web tooling, and I'd…

I added ActivityPub to this blog

I built this blog with Jikji, a static site generator I wrote myself, almost five years ago. Back then I barely knew TypeScript or modern web tooling, and I'd never implemented ActivityPub. TypeScript and modern web tooling are second nature to me now, and ActivityPub has become central to my work. I maintain Fedify, for whatever that's worth, and it bothered me that my own blog wasn't federated. So I fixed that.

The old stack: Jikji and PHP

This blog used to run on Jikji, a static site generator I wrote myself in Deno. Calling it a static site generator is a bit of a stretch, though. Like old Movable Type installations, it didn't just produce HTML; it generated a bit of PHP too. That PHP existed almost entirely for HTTP content negotiation: it read the browser's Accept-Language header and chose among Korean mixed script, hangul-only Korean, English, and Japanese. That's all it did.

I first considered adding a thin ActivityPub implementation directly in PHP, since I was already using it. But I wasn't really writing that PHP by hand; Jikji generated it for me, and I had no interest in hand-coding PHP myself. Federating meant delivering a Create(Article) activity to followers whenever a new post went up, which meant I'd need something like a message queue. Bolting a message queue onto Jikji's generated PHP felt, to me at least, like more complexity than it was worth maintaining. And honestly, with Fedify already around, I had no desire to implement ActivityPub from scratch again.

So I ripped out PHP entirely and decided to bring in Fedify instead.

The new stack: Astro and Netlify

The first decision was to drop Jikji and PHP for Astro, a JavaScript framework built for static-content-heavy sites. I chose Astro largely because it already had a @fedify/astro integration.

I reused as much of the existing CSS and HTML as I could. I'm happy with the current design, and redoing it alongside everything else felt like scope creep waiting to happen. Permalinks stayed exactly as they were. I wanted to replace the stack underneath without visitors noticing anything had changed at all.

For hosting, I went back and forth between Cloudflare Workers and Netlify, and settled on Netlify partly because Fedify had never run there before, and this seemed like a good excuse to add that support. I've hosted static sites on Netlify plenty of times, but this was my first time pairing it with edge functions. The idea of a mostly static site with a few dynamic slices reminded me of the late-nineties web, when a site was static HTML except for whatever lived in /cgi-bin/.

Publishing used to mean committing a Markdown file to Git, pushing, letting GitHub Actions build the static site, and deploying it over SFTP. Now GitHub Actions is out of the build pipeline entirely, since Netlify builds the site itself. It ended up simpler overall.

I'm happy with Astro, and the migration went smoothly. It beats Jikji, which I'd barely touched since building it five years ago. Jikji is now archived; there's no reason left for me to keep maintaining it.

Fitting Fedify into Astro

Updating @fedify/astro

Once I actually tried to add Fedify to Astro, I ran into a problem: @fedify/astro didn't support Astro 7, the current version. The Astro APIs it relied on hadn't changed much internally, but the package's declared compatibility range, and its tests, only went up to Astro 5. So before I could federate the blog, I had to fix @fedify/astro first.

That meant more than widening a version range. The existing tests built a fake Astro context and called the middleware directly, which couldn't catch problems with Vite's SSR configuration, compatibility across adapters, or request routing on a built server. So I wrote new compatibility tests that pack @fedify/astro for real, install it into a small Astro app, build and start the app, and send real HTTP requests to it.

Those tests check, across Astro 5, 6, and 7, that HTML requests reach Astro's pages, that ActivityPub and WebFinger requests are handled by Fedify, and that Astro's 404 Not Found still applies to everything else. For Astro 7 specifically, I also run the tests against Deno and Bun, not just the Node.js adapter.

That work has already been merged upstream and will ship in Fedify 2.4.0.

Static pages, dynamic endpoints

The Astro project as a whole builds with server output, but the existing blog pages are still prerendered, same as before. WebFinger, the actor, the inbox and outbox, the followers collection, and ActivityPub objects are the exceptions: Fedify handles those dynamically, per request. The middleware @fedify/astro provides looks at a request's URL and Accept header and only intercepts what Fedify is meant to handle. The same URL can return the existing Astro page for an HTML request and a Fedify-built object for an ActivityPub one.

What visitors see is still, for all practical purposes, a static site. Nearly all the new dynamic surface lives somewhere only other fediverse servers ever touch. That's the CGI comparison again.

Person and Article

Adding ActivityPub also meant deciding what counts as an actor here, and what counts as an object. I gave the blog's actor a Person type. Publishing itself is automated, but the actor represents me, the person writing these posts, not a piece of software or a service. So the handle is @hongminhee@writings.hongminhee.org, and the actor's web URL points at the blog.

Each post gets an Article. It has a title and a body, and it lives at its own permalink as a long-form document, which fits Article better than Note. Most major ActivityPub implementations support Article these days, Mastodon included. Human-facing permalinks stayed put; ActivityPub objects got their own URIs instead, shaped like /ap/articles/{year}/{month}/{slug}. Article's url points back at the original permalink, so the object's identity and the web page people actually read stay separate.

Multiple languages took more thought. Representing each language as its own Article would scatter likes and shares for the same post across several objects. So I merged the Korean mixed script, hangul-only Korean, English, and Japanese versions under a single Article, all sharing one permalink. Title, summary, and body each carry language-tagged values for every version, which serialize to JSON-LD as nameMap, summaryMap, and contentMap. For implementations that don't handle per-language values, name, summary, and content also carry a default: English if there's an English version, Korean mixed script otherwise. Each language's HTML page also gets a Link on Article's url, tagged with hreflang.

That way, a receiving server that understands multiple languages can pick a title and body matching the reader's language, and one that doesn't can still fall back to the default. In practice, though, I know of hardly any ActivityPub implementation that renders these multilingual values properly yet. There's an open issue for it on Mastodon's tracker, and a similar proposal on Hackers' Pub's, but neither has a timeline. Some of that is probably a UI design problem as much as anything else.

Running Fedify on Netlify

Unlike serving plain static files, an ActivityPub server needs some state that outlives any single deploy. The actor's signing key can't rotate on every deploy. The followers list can't disappear on the next one either. Both live in Netlify Database.

Incoming and outgoing activities go through a message queue built on Async Workloads. Delivery can be slow or fail outright depending on the receiving server, so it can't all happen inside the function handling the HTTP request. Queuing it separates accepting a request from actually delivering it, and failed deliveries can be retried later. Fedify already abstracts this, with pluggable backend adapters, but there wasn't yet an adapter for Netlify's Async Workloads. So I wrote the @fedify/netlify package, which uses Async Workloads as the queue and keeps delivery-order state in Netlify Database.

Announcing new posts to the fediverse turned out to be a separate problem. A static site finishing its build doesn't tell a running ActivityPub server anything about which posts changed. So on every successful production deploy, I diff the current post list against the previous deploy's. New posts get a Create(Article); edited ones, whether the content or just the timestamp changed, get an Update(Article); removed ones get a Delete(Article). All of it goes out to followers. Retries reuse the same activity ID for the same change, and deploy ordering is checked so that an older deploy syncing late can't undo a newer one.

Netlify's deploy previews and branch deploys have federation turned off entirely. Otherwise every preview would spin up an actor claiming to be this same blog, and a test deploy could end up sending activities to real followers. Locally, I develop against an in-memory store and queue; production is the only place using the persistent database and queue.

Fedify now runs on Netlify Functions, alongside Deno Deploy and Cloudflare Workers, on top of its usual support for Node.js, Deno, and Bun.

Wrapping up

None of this gives the blog a timeline, a reply box, or any other social feature. Writing and reading still work the way they always did, and the permalinks and design are basically untouched. What changed is that the blog, and every post on it, now has a name and address the fediverse understands. Follow @hongminhee@writings.hongminhee.org to get new posts, or look up a post's ActivityPub object URI to find the original.

I've maintained Fedify long enough to show other developers how to implement ActivityPub, and I dogfooded it plenty while building Hollo and Hackers' Pub. But this was the first time I'd added it to a site that was already live, and static at that. Along the way I got a compatibility test suite for the Astro integration, Netlify support, and a handful of deployment and operational problems that no amount of reading docs or unit tests would have surfaced. It turns out Fedify isn't just for building new social networks from scratch; it works just as well for bringing an existing site into the fediverse without changing how it looks.

hackers.pub

Hackers' Pub: Local timeline

@kopper@not-brain.d.on-t.work · Reply to kopper :colon_three:
one interesting thing with sqlite is its main downside (1 concurrent writer max) can be alleviated if you partition your database, which in something like postgres is only needed if you Scale Massively. like, the bluesky pds software uses sqlite, but partitions it so each user has their own sqlite file, with the expectation that one user won't ever write fast enough for sqlite to be the bottleneck.

this has an interesting interaction with tools like ORMs that aim to be portable among database systems, because they are flat out not built for it and lead you to having One Sqlite For Everything, and can even make doing the correct thing harder by assuming you'll not really live dis/connect to databases all too often. i imagine this is why stuff like nextcloud or gotosocial or forgejo on sqlite is such a slog. you really have to build
for sqlite if you want to use sqlite and can't Just have it be Another Option
@hongminhee@hollo.social

Sharing my post from last November once again today. As I continue to navigate this journey of self-discovery, celebrating my very first International Non-Binary People's Day feels quietly meaningful. Claiming my own space and identity is still a work in progress, but I am deeply grateful for the quiet support and honest conversations that have brought me here. Wishing peace and warmth to everyone celebrating today. 💜💛

@hongminhee@hollo.social

Growing up, I never quite fit into the typical mold of masculinity. My name, Minhee, doesn't help either—it carries a fairly feminine connotation in Korean, which only made it harder to feel any strong sense of male identity.

For the longest time, society categorized me as male, and I didn't really push back against that label. I just went along with it.

But over time, I've realized something important: I don't just lack the traits society expects from men—I have zero interest in pursuing what people call “masculine values.” Sometimes I find myself actively rejecting them.

Things really clicked after I met my spouse, Lisa (@tokolovesme). Through our deep, honest conversations, I finally found words for something I'd felt all along: I'm fundamentally different from a typical cisgender, heterosexual man.

I have come to identify as non-binary and bisexual.

After introducing myself as a man for my entire life, claiming this identity—actually saying “I am non-binary”—still feels new and awkward. But I'm starting to share this truth with the people close to me, one conversation at a time.

@hongminhee@hollo.social

Growing up, I never quite fit into the typical mold of masculinity. My name, Minhee, doesn't help either—it carries a fairly feminine connotation in Korean, which only made it harder to feel any strong sense of male identity.

For the longest time, society categorized me as male, and I didn't really push back against that label. I just went along with it.

But over time, I've realized something important: I don't just lack the traits society expects from men—I have zero interest in pursuing what people call “masculine values.” Sometimes I find myself actively rejecting them.

Things really clicked after I met my spouse, Lisa (@tokolovesme). Through our deep, honest conversations, I finally found words for something I'd felt all along: I'm fundamentally different from a typical cisgender, heterosexual man.

I have come to identify as non-binary and bisexual.

After introducing myself as a man for my entire life, claiming this identity—actually saying “I am non-binary”—still feels new and awkward. But I'm starting to share this truth with the people close to me, one conversation at a time.

@hongminhee@hollo.social · Reply to Sashin

@sashin I mainly work on my F/OSS projects. I need a web browser to check issues, PRs, and related materials, and I use Zed and Ghostty for coding. Zed actually has a built-in terminal, but I find built-in editor terminals to be too cramped, so I don't really use them. I usually assign one project per workspace, but sometimes I'll assign two or more different issues from the same project to separate workspaces.