diff --git a/packages/control-plane/src/auth/github-app.test.ts b/packages/control-plane/src/auth/github-app.test.ts index bc4d0914e..42ae691c4 100644 --- a/packages/control-plane/src/auth/github-app.test.ts +++ b/packages/control-plane/src/auth/github-app.test.ts @@ -6,19 +6,17 @@ import { INSTALLATION_TOKEN_CACHE_MAX_AGE_MS, INSTALLATION_TOKEN_MIN_REMAINING_MS, } from "./github-app"; +import type { CacheStore } from "../cache/cache-store"; -class FakeKvNamespace { +class FakeCacheStore implements CacheStore { private readonly store = new Map(); - async get(key: string, type?: "text" | "json"): Promise { + async get(key: string): Promise { const value = this.store.get(key); if (value == null) { return null; } - if (type === "json") { - return JSON.parse(value) as T; - } - return value; + return JSON.parse(value) as T; } async put(key: string, value: string): Promise { @@ -124,7 +122,7 @@ describe("github-app utilities", () => { it("reads valid token from KV cache", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); - const kv = new FakeKvNamespace(); + const cacheStore = new FakeCacheStore(); const config = { appId: `app-kv-${Date.now()}`, @@ -132,7 +130,7 @@ describe("github-app utilities", () => { installationId: "installation-2", }; - await kv.put( + await cacheStore.put( `github:installation-token:v1:${config.appId}:${config.installationId}`, JSON.stringify({ token: "token-from-kv", @@ -143,7 +141,7 @@ describe("github-app utilities", () => { ); const token = await getCachedInstallationToken(config, { - REPOS_CACHE: kv as unknown as KVNamespace, + cacheStore, }); expect(token).toBe("token-from-kv"); diff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts index eabeb0a32..fd3632a83 100644 --- a/packages/control-plane/src/auth/github-app.ts +++ b/packages/control-plane/src/auth/github-app.ts @@ -10,6 +10,7 @@ */ import type { InstallationRepository } from "@open-inspect/shared"; +import type { CacheStore } from "../cache/cache-store"; /** Timeout for individual GitHub API requests (ms). */ export const GITHUB_FETCH_TIMEOUT_MS = 60_000; @@ -26,7 +27,7 @@ export const INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS = 3600; const INSTALLATION_TOKEN_CACHE_KEY_PREFIX = "github:installation-token:v1"; interface InstallationTokenCacheBindings { - REPOS_CACHE?: KVNamespace; + cacheStore?: CacheStore; } interface CachedInstallationToken { @@ -249,28 +250,28 @@ function isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()) return nowEpochMs < cached.expiresAtEpochMs - INSTALLATION_TOKEN_MIN_REMAINING_MS; } -async function readInstallationTokenFromKv( +async function readInstallationTokenFromCache( env: InstallationTokenCacheBindings | undefined, cacheKey: string ): Promise { - if (!env?.REPOS_CACHE) { + if (!env?.cacheStore) { return null; } try { - const cached = await env.REPOS_CACHE.get(cacheKey, "json"); + const cached = await env.cacheStore.get(cacheKey); return cached ?? null; } catch { return null; } } -async function writeInstallationTokenToKv( +async function writeInstallationTokenToCache( env: InstallationTokenCacheBindings | undefined, cacheKey: string, cached: CachedInstallationToken ): Promise { - if (!env?.REPOS_CACHE) { + if (!env?.cacheStore) { return; } @@ -287,7 +288,7 @@ async function writeInstallationTokenToKv( ); try { - await env.REPOS_CACHE.put(cacheKey, JSON.stringify(cached), { expirationTtl: ttlSeconds }); + await env.cacheStore.put(cacheKey, JSON.stringify(cached), { expirationTtl: ttlSeconds }); } catch { // Cache failures are non-fatal. } @@ -300,12 +301,12 @@ async function invalidateInstallationTokenCache( installationTokenMemoryCache.delete(cacheKey); installationTokenRefreshInFlight.delete(cacheKey); - if (!env?.REPOS_CACHE) { + if (!env?.cacheStore) { return; } try { - await env.REPOS_CACHE.delete(cacheKey); + await env.cacheStore.delete(cacheKey); } catch { // Cache invalidation failures are non-fatal. } @@ -329,7 +330,7 @@ async function refreshInstallationToken( }; installationTokenMemoryCache.set(cacheKey, cached); - await writeInstallationTokenToKv(env, cacheKey, cached); + await writeInstallationTokenToCache(env, cacheKey, cached); return cached; } @@ -350,10 +351,10 @@ export async function getCachedInstallationToken( return memoryCached.token; } - const kvCached = await readInstallationTokenFromKv(env, cacheKey); - if (kvCached && isTokenUsable(kvCached)) { - installationTokenMemoryCache.set(cacheKey, kvCached); - return kvCached.token; + const persistentCached = await readInstallationTokenFromCache(env, cacheKey); + if (persistentCached && isTokenUsable(persistentCached)) { + installationTokenMemoryCache.set(cacheKey, persistentCached); + return persistentCached.token; } const inFlight = installationTokenRefreshInFlight.get(cacheKey); diff --git a/packages/control-plane/src/cache/cache-store.ts b/packages/control-plane/src/cache/cache-store.ts new file mode 100644 index 000000000..aaedad0f8 --- /dev/null +++ b/packages/control-plane/src/cache/cache-store.ts @@ -0,0 +1,13 @@ +export interface CacheStore { + get(key: string): Promise; + put(key: string, value: string, opts?: { expirationTtl?: number }): Promise; + delete(key: string): Promise; +} + +export function createKvCacheStore(kv: KVNamespace): CacheStore { + return { + get: (key: string) => kv.get(key, "json"), + put: (key, value, opts) => kv.put(key, value, opts), + delete: (key) => kv.delete(key), + }; +} diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 9bd275ef0..3af5fdb5d 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -5,6 +5,7 @@ import type { CorrelationContext } from "../logger"; import type { RequestMetrics } from "../db/instrumented-d1"; import type { Env } from "../types"; +import { createKvCacheStore } from "../cache/cache-store"; import { getGitHubAppConfig } from "../auth/github-app"; import type { Logger } from "../logger"; import { @@ -74,7 +75,7 @@ export function createRouteSourceControlProvider(env: Env): SourceControlProvide provider, github: { appConfig: appConfig ?? undefined, - kvCache: env.REPOS_CACHE, + cacheStore: createKvCacheStore(env.REPOS_CACHE), }, ...(env.GITLAB_ACCESS_TOKEN && { gitlab: { diff --git a/packages/control-plane/src/session/durable-object.ts b/packages/control-plane/src/session/durable-object.ts index eaae33cf5..d54ac9476 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -54,6 +54,7 @@ import type { } from "../types"; import type { SessionRow, ArtifactRow, SandboxRow } from "./types"; import { SessionRepository } from "./repository"; +import { createKvCacheStore } from "../cache/cache-store"; import { SessionWebSocketManagerImpl, type SessionWebSocketManager } from "./websocket-manager"; import { SessionPullRequestService } from "./pull-request-service"; import { RepoSecretsStore } from "../db/repo-secrets"; @@ -535,7 +536,7 @@ export class SessionDO extends DurableObject { provider, github: { appConfig: appConfig ?? undefined, - kvCache: this.env.REPOS_CACHE, + cacheStore: createKvCacheStore(this.env.REPOS_CACHE), }, }); } @@ -581,7 +582,10 @@ export class SessionDO extends DurableObject { scmProvider === "gitlab" ? () => Promise.resolve(this.env.GITLAB_ACCESS_TOKEN ?? null) : appConfig - ? () => getCachedInstallationToken(appConfig, this.env) + ? () => + getCachedInstallationToken(appConfig, { + cacheStore: createKvCacheStore(this.env.REPOS_CACHE), + }) : () => Promise.resolve(null); return createDaytonaProvider( diff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts index 37386be71..e6caf8a89 100644 --- a/packages/control-plane/src/source-control/providers/github-provider.ts +++ b/packages/control-plane/src/source-control/providers/github-provider.ts @@ -45,11 +45,11 @@ export class GitHubSourceControlProvider implements SourceControlProvider { readonly name = "github"; private readonly appConfig?: GitHubProviderConfig["appConfig"]; - private readonly kvCache?: KVNamespace; + private readonly cacheStore?: GitHubProviderConfig["cacheStore"]; constructor(config: GitHubProviderConfig = {}) { this.appConfig = config.appConfig; - this.kvCache = config.kvCache; + this.cacheStore = config.cacheStore; } /** @@ -216,7 +216,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { this.appConfig, config.owner, config.name, - this.kvCache ? { REPOS_CACHE: this.kvCache } : undefined + this.cacheStore ? { cacheStore: this.cacheStore } : undefined ); if (!repo) { return null; @@ -250,7 +250,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { try { const result = await listInstallationRepositories( this.appConfig, - this.kvCache ? { REPOS_CACHE: this.kvCache } : undefined + this.cacheStore ? { cacheStore: this.cacheStore } : undefined ); return result.repos; } catch (error) { @@ -278,7 +278,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { this.appConfig, config.owner, config.name, - this.kvCache ? { REPOS_CACHE: this.kvCache } : undefined + this.cacheStore ? { cacheStore: this.cacheStore } : undefined ); } catch (error) { throw SourceControlProviderError.fromFetchError( diff --git a/packages/control-plane/src/source-control/providers/types.ts b/packages/control-plane/src/source-control/providers/types.ts index 016201676..2f4f23713 100644 --- a/packages/control-plane/src/source-control/providers/types.ts +++ b/packages/control-plane/src/source-control/providers/types.ts @@ -3,6 +3,7 @@ */ import type { GitHubAppConfig } from "../../auth/github-app"; +import type { CacheStore } from "../../cache/cache-store"; /** * Configuration for GitHubSourceControlProvider. @@ -10,8 +11,8 @@ import type { GitHubAppConfig } from "../../auth/github-app"; export interface GitHubProviderConfig { /** GitHub App configuration (required for push auth) */ appConfig?: GitHubAppConfig; - /** KV namespace for caching installation tokens */ - kvCache?: KVNamespace; + /** Cache store for caching installation tokens */ + cacheStore?: CacheStore; } /**