Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 66 additions & 14 deletions src/dispatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,22 @@ export const actorDispatcher = (hostDataContextLoader: HostDataContextLoader) =>
export const keypairDispatcher = (
accountService: AccountService,
hostDataContextLoader: HostDataContextLoader,
) =>
async function keypairDispatcher(ctx: FedifyContext, identifier: string) {
) => {
const MAX_CACHE_SIZE = 1000;
const KEY_TTL_MS = 30 * 60 * 1000; // 30 minutes
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What's the reasoning behind 30 min? It that an arbitrary limit?

const cryptoKeyCache = new Map<
number,
{
publicKey: CryptoKey;
privateKey: CryptoKey;
createdAt: number;
}
>();

return async function keypairDispatcher(
ctx: FedifyContext,
identifier: string,
) {
const hostData = await hostDataContextLoader.loadDataForHost(ctx.host);

if (isError(hostData)) {
Expand Down Expand Up @@ -145,6 +159,14 @@ export const keypairDispatcher = (

const { account } = getValue(hostData);

const cached = cryptoKeyCache.get(account.id);
if (cached) {
if (Date.now() - cached.createdAt < KEY_TTL_MS) {
return [cached];
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On cache hits you return [cached], where cached includes createdAt, but on cache misses you return [imported] without createdAt. This makes the dispatcher’s returned element shape inconsistent and also exposes the internal cache entry object by reference. Consider returning only { publicKey, privateKey } (and keeping createdAt internal) and/or returning a shallow copy so callers can’t mutate cache state.

Suggested change
return [cached];
return [
{
publicKey: cached.publicKey,
privateKey: cached.privateKey,
},
];

Copilot uses AI. Check for mistakes.
}
Comment on lines +162 to +166
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new CryptoKey caching/TTL behavior in keypairDispatcher isn’t exercised by the current unit tests (they only cover the non-cached success/error paths). Adding a test that calls the dispatcher twice and asserts the second call avoids accountService.getKeyPair/importJwk, plus a TTL-expiry test, would help prevent regressions.

Copilot uses AI. Check for mistakes.
cryptoKeyCache.delete(account.id);
}

const keyPair = await accountService.getKeyPair(account.id);

if (isError(keyPair)) {
Expand Down Expand Up @@ -176,18 +198,47 @@ export const keypairDispatcher = (
const { publicKey, privateKey } = getValue(keyPair);

try {
return [
{
publicKey: await importJwk(
JSON.parse(publicKey) as JsonWebKey,
'public',
),
privateKey: await importJwk(
JSON.parse(privateKey) as JsonWebKey,
'private',
),
},
];
const imported = {
publicKey: await importJwk(
JSON.parse(publicKey) as JsonWebKey,
'public',
),
privateKey: await importJwk(
JSON.parse(privateKey) as JsonWebKey,
'private',
),
};

const now = Date.now();

// Evict expired entries
for (const [key, entry] of cryptoKeyCache) {
if (now - entry.createdAt >= KEY_TTL_MS) {
cryptoKeyCache.delete(key);
}
}

// If still full, evict the oldest entry
if (cryptoKeyCache.size >= MAX_CACHE_SIZE) {
let oldestKey: number | undefined;
let oldestTime = Infinity;
for (const [key, entry] of cryptoKeyCache) {
if (entry.createdAt < oldestTime) {
oldestTime = entry.createdAt;
oldestKey = key;
}
}
if (oldestKey !== undefined) {
cryptoKeyCache.delete(oldestKey);
}
}

cryptoKeyCache.set(account.id, {
...imported,
createdAt: now,
});

return [imported];
} catch (error) {
ctx.data.logger.error(
'Could not parse keypair for {host} (identifier: {identifier}): {error}',
Expand All @@ -200,6 +251,7 @@ export const keypairDispatcher = (
return [];
}
};
};

export function createAcceptHandler(accountService: AccountService) {
return async function handleAccept(ctx: FedifyContext, accept: Accept) {
Expand Down
55 changes: 46 additions & 9 deletions src/http/host-data-context-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,60 @@ import type { Knex } from 'knex';
import type { Account } from '@/account/account.entity';
import type { KnexAccountRepository } from '@/account/account.repository.knex';
import type { Result } from '@/core/result';
import { error, ok } from '@/core/result';
import { error, isError, ok } from '@/core/result';
import type { Site } from '@/site/site.service';

type HostDataResult = Result<
{ site: Site; account: Account },
'site-not-found' | 'account-not-found' | 'multiple-users-for-site'
>;

interface CacheEntry {
result: HostDataResult;
expiry: number;
}

export class HostDataContextLoader {
private static readonly CACHE_TTL_MS = 60_000;
private static readonly MAX_CACHE_SIZE = 1000;
private static readonly CACHE_ENABLED =
process.env.NODE_ENV === 'production';
private readonly cache = new Map<string, CacheEntry>();

constructor(
readonly db: Knex,
readonly accountRepository: KnexAccountRepository,
) {}

async loadDataForHost(
host: string,
): Promise<
Result<
{ site: Site; account: Account },
'site-not-found' | 'account-not-found' | 'multiple-users-for-site'
>
> {
async loadDataForHost(host: string): Promise<HostDataResult> {
if (!HostDataContextLoader.CACHE_ENABLED) {
return this.queryDataForHost(host);
}

Comment on lines +31 to +35
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new caching path (enabled in production) isn’t covered by the existing HostDataContextLoader tests. Adding a test that forces caching on (e.g., temporarily setting NODE_ENV/exposing a flag and using fake timers) would help ensure cache-hit behavior and eviction/expiry logic stay correct.

Copilot uses AI. Check for mistakes.
const now = Date.now();
const cached = this.cache.get(host);

if (cached && cached.expiry > now) {
return cached.result;
}

const result = await this.queryDataForHost(host);

if (!isError(result)) {
if (this.cache.size >= HostDataContextLoader.MAX_CACHE_SIZE) {
this.cache.delete(this.cache.keys().next().value!);
}
Comment on lines +36 to +48
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a cached entry is expired, it is left in the Map (you don’t delete it on expiry). Additionally, eviction runs whenever cache.size >= MAX_CACHE_SIZE even if you’re overwriting an existing host key (which doesn’t increase size), causing unnecessary eviction/churn and potentially dropping a valid entry while keeping expired ones. Consider deleting expired entries on lookup, sweeping expired entries before size-based eviction, and only evicting when inserting a new host key (e.g., !cache.has(host)).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: @rmgpinto think this is good feedback, I'd also suggest deleting the expired key as soon as we know it has expired (e.g. on line 42), so that we don't evict more keys than necessary


this.cache.set(host, {
result,
expiry: now + HostDataContextLoader.CACHE_TTL_MS,
});
}

return result;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we should cover the cache read/delete/set behaviour in automated tests, e.g. first call makes the database query and set cache, second one reads from cache, one after x min deletes key and re-fetches


private async queryDataForHost(host: string): Promise<HostDataResult> {
const results = await this.db('sites')
.leftJoin('users', 'users.site_id', 'sites.id')
.leftJoin('accounts', 'accounts.id', 'users.account_id')
Expand Down