Skip to content
Merged
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
16 changes: 7 additions & 9 deletions packages/control-plane/src/auth/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

async get<T>(key: string, type?: "text" | "json"): Promise<T | string | null> {
async get<T>(key: string): Promise<T | null> {
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<void> {
Expand Down Expand Up @@ -124,15 +122,15 @@ 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()}`,
privateKey: "-----BEGIN PRIVATE KEY-----\nAA==\n-----END PRIVATE KEY-----",
installationId: "installation-2",
};

await kv.put(
await cacheStore.put(
`github:installation-token:v1:${config.appId}:${config.installationId}`,
JSON.stringify({
token: "token-from-kv",
Expand All @@ -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");
Expand Down
29 changes: 15 additions & 14 deletions packages/control-plane/src/auth/github-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<CachedInstallationToken | null> {
if (!env?.REPOS_CACHE) {
if (!env?.cacheStore) {
return null;
}

try {
const cached = await env.REPOS_CACHE.get<CachedInstallationToken>(cacheKey, "json");
const cached = await env.cacheStore.get<CachedInstallationToken>(cacheKey);
return cached ?? null;
} catch {
return null;
}
}

async function writeInstallationTokenToKv(
async function writeInstallationTokenToCache(
env: InstallationTokenCacheBindings | undefined,
cacheKey: string,
cached: CachedInstallationToken
): Promise<void> {
if (!env?.REPOS_CACHE) {
if (!env?.cacheStore) {
return;
}

Expand All @@ -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.
}
Expand All @@ -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.
}
Expand All @@ -329,7 +330,7 @@ async function refreshInstallationToken(
};

installationTokenMemoryCache.set(cacheKey, cached);
await writeInstallationTokenToKv(env, cacheKey, cached);
await writeInstallationTokenToCache(env, cacheKey, cached);
return cached;
}

Expand All @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions packages/control-plane/src/cache/cache-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface CacheStore {
get<T>(key: string): Promise<T | null>;
put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
delete(key: string): Promise<void>;
}

export function createKvCacheStore(kv: KVNamespace): CacheStore {
return {
get: <T>(key: string) => kv.get<T>(key, "json"),
put: (key, value, opts) => kv.put(key, value, opts),
delete: (key) => kv.delete(key),
};
}
3 changes: 2 additions & 1 deletion packages/control-plane/src/routes/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 6 additions & 2 deletions packages/control-plane/src/session/durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -535,7 +536,7 @@ export class SessionDO extends DurableObject<Env> {
provider,
github: {
appConfig: appConfig ?? undefined,
kvCache: this.env.REPOS_CACHE,
cacheStore: createKvCacheStore(this.env.REPOS_CACHE),
},
});
}
Expand Down Expand Up @@ -581,7 +582,10 @@ export class SessionDO extends DurableObject<Env> {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions packages/control-plane/src/source-control/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
*/

import type { GitHubAppConfig } from "../../auth/github-app";
import type { CacheStore } from "../../cache/cache-store";

/**
* Configuration for GitHubSourceControlProvider.
*/
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;
}

/**
Expand Down
Loading