Skip to content

Commit 3bf878a

Browse files
committed
Fixed KnexKvStore returning null for missing keys
Fedify's KvStore contract requires get() to resolve to undefined when a key does not exist; null is reserved for stored values. Since Fedify introduced persistent negative key caching, its KvKeyCache strictly distinguishes the two: undefined is a cache miss (fetch the key), while null marks the key as known-unavailable and skips fetching entirely. Because KnexKvStore returned null for missing rows, every signing key that had never been cached was misreported as a cached negative entry, so Fedify never fetched the sender's key and inbound HTTP signature verification failed with 401 for any new peer. This only affected deployments using the default MySQL-backed store (self-hosters) - the Redis store used in production follows the contract correctly. Also handle stored JSON null values on read: Fedify persists null to mark failed key lookups, and reading such a row previously threw a TypeError in Object.hasOwn(). Fixes #1961
1 parent 0d75f3c commit 3bf878a

2 files changed

Lines changed: 21 additions & 6 deletions

File tree

src/knex.kvstore.integration.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ describe('KnexKvStore', () => {
1818
const store = KnexKvStore.create(client, table, logger);
1919

2020
// checkReadingUnsetKey
21+
// Fedify's KvStore contract requires undefined (not null) for missing
22+
// keys - null means a stored negative entry (see checkNullValue)
2123
{
2224
const actual = await store.get(['unsetkey']);
23-
const expected = null;
24-
expect(actual).toEqual(expected);
25+
expect(actual).toBeUndefined();
2526
}
2627

2728
// checkReadingSetKey
@@ -46,8 +47,15 @@ describe('KnexKvStore', () => {
4647
await store.set(['deleted'], { initial: 'value' });
4748
await store.delete(['deleted']);
4849
const actual = await store.get(['deleted']);
49-
const expected = null;
50-
expect(actual).toEqual(expected);
50+
expect(actual).toBeUndefined();
51+
}
52+
53+
// checkNullValue - Fedify's KvKeyCache stores null to mark a key
54+
// lookup as failed, and must read it back as null (not undefined)
55+
{
56+
await store.set(['nullvalue'], null);
57+
const actual = await store.get(['nullvalue']);
58+
expect(actual).toBeNull();
5159
}
5260
});
5361

@@ -131,7 +139,7 @@ describe('KnexKvStore', () => {
131139

132140
const result = await store.get(['expired-key']);
133141

134-
expect(result).toBeNull();
142+
expect(result).toBeUndefined();
135143
});
136144

137145
it('Deletes expired entries from the database on read', async () => {

src/knex.kvstore.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,22 @@ export class KnexKvStore implements KvStore {
4646
key: this.keyToString(key),
4747
};
4848
const row = await this.knex(this.table).where(query).first();
49+
// Fedify distinguishes a missing key (undefined) from a stored null:
50+
// its KvKeyCache treats null as a cached "key is unavailable" marker
51+
// and skips fetching the key entirely, so returning null for missing
52+
// keys breaks HTTP signature verification for never-seen senders.
4953
if (!row) {
50-
return null;
54+
return undefined;
5155
}
5256
if (row.expires !== null && row.expires <= new Date()) {
5357
this.logging.debug(
5458
`KnexKvStore: Deleting expired key ${key}`,
5559
keyInfo,
5660
);
5761
await this.knex(this.table).where(query).del();
62+
return undefined;
63+
}
64+
if (row.value === null) {
5865
return null;
5966
}
6067
if (Object.hasOwn(row.value, '@@BOOLEAN@@')) {

0 commit comments

Comments
 (0)