Mastodon unable to follow: KnexKvStore in ActivityPub integration breaks Fedify cache misses and crashes on empty row.value

Issue Summary

Explain roughly what’s wrong
The KnexKvStore implementation within the Ghost ActivityPub integration handles cache misses and empty store values incorrectly, leading to broken incoming federation (HTTP 401) and server crashes (HTTP 500):

  1. Cache Miss Bug (401 Unauthorized): KnexKvStore.get() returns null instead of undefined when a key does not exist or when an expired key is deleted. Fedify interprets null as “entry exists in cache but is unavailable” rather than a cache miss. Consequently, Fedify skips fetching actor public keys over HTTP and rejects incoming federation requests (e.g., Follow activities) with 401 Unauthorized.
  2. TypeError Crash (500 Internal Server Error): In KnexKvStore.get(), Object.hasOwn(row.value, "@@BOOLEAN@@") is called without validating that row.value is non-null and an object. If row.value is null or undefined, Node.js throws TypeError: Cannot convert undefined or null to object.

What did you expect to happen?

  • KnexKvStore.get() should return undefined on cache misses or key deletions so Fedify performs network requests to fetch required keys.
  • KnexKvStore.get() should safely validate row.value before evaluating object properties.

Steps to Reproduce

  1. Deploy Ghost with the ActivityPub service enabled using MySQL as the KvStore backend.
  2. Attempt to follow the Ghost ActivityPub account from a remote Mastodon instance.
  3. Observe incoming POST requests to /.ghost/activitypub/inbox/index failing with 401 Unauthorized.
  4. Inspect logs to see fedify·sig·key: Entry '...' found in cache, but it is unavailable.
  5. In scenarios where row.value is empty/null, observe request failure with TypeError: Cannot convert undefined or null to object in _KnexKvStore.get.

Setup Information

  • Ghost Version: 6.59.0
  • ActivityPub Service Version: 1.2.7 (@fedify/fedify 2.3.2)
  • Node.js Version: v22.23.1
  • How did you install Ghost? Self-hosted via Docker Compose
  • Host & Operating System: Linux x86_64
  • Database Type: MySQL 8.0.44
  • Browser & OS Version: N/A (Server-to-server ActivityPub federation bug)

Relevant Log / Error Output

1. Cache miss returning null triggering 401 Unauthorized:

DBG activitypub: KnexKvStore: Get key _fedify,publicKey,[https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232](https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232)
DBG fedify·sig·key: Entry '[https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232](https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232)' found in cache, but it is unavailable.
DBG activitypub: KnexKvStore: Get key _fedify,publicKey,__fetchError,[https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232](https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232)
DBG fedify·sig·key: Entry '[https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232](https://mastodon.social/ap/users/117115874267027772#rsa-0a8fa5b2ef1e0232)' found in cache, but no fetch failure details are available.
WRN fedify·federation·http: 'POST' '/.ghost/activitypub/inbox/index': 401

Crash on missing row.value check triggering 500 Internal Server Error:

ERR fedify·federation·http: An error occurred while serving request 'POST' 'https://chromozone.org/.ghost/activitypub/inbox/index': TypeError: Cannot convert undefined or null to object
at Function.hasOwn ()
at _KnexKvStore.get (file:///opt/activitypub/dist/app.js:11967:16)
at async KvKeyCache.get (file:///opt/activitypub/node_modules/.pnpm/@fedify+fedify@2.3.2/node_modules/@fedify/fedify/dist/middleware-B2rDMvhv.js:1422:22)

Proposed Fix

In src/knex.kvstore.ts (and compiled dist/app.js)

// Return undefined instead of null on cache misses
if (!row) {
return undefined;
}
if (row.expires !== null && row.expires <= new Date()) {
await this.knex(this.table).where(query).del();
return undefined;
}

// Add null & type check before Object.hasOwn
if (row.value && typeof row.value === "object" && Object.hasOwn(row.value, "@@BOOLEAN@@")) {
return row.value["@@BOOLEAN@@"];
}
return row.value;

Hey @Sammy,

Thanks for the bug report! This is valid and a fix has been released in ActivityPub v1.2.8.

Have a great day,

Sag
Product Engineer @ Ghost