diff --git a/src/app.ts b/src/app.ts index 0ade967..0c8a9ce 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,10 @@ export function createApp(adapters: readonly AdapterModule[]) { app.use('*', async (c, next) => { const startedAt = Date.now() - c.set('requestId', crypto.randomUUID()) + const requestId = c.req.header('cf-ray') || crypto.randomUUID() + const correlationId = validCorrelationId(c.req.header('x-correlation-id')) ?? requestId + c.set('requestId', requestId) + c.set('correlationId', correlationId) try { await next() } finally { @@ -18,6 +21,7 @@ export function createApp(adapters: readonly AdapterModule[]) { const record = JSON.stringify({ event: 'request.complete', requestId: c.get('requestId'), + correlationId: c.get('correlationId'), method: c.req.method, path: new URL(c.req.url).pathname, status: c.res.status, @@ -54,6 +58,10 @@ export function createApp(adapters: readonly AdapterModule[]) { return app } +function validCorrelationId(value: string | undefined) { + return value && /^[0-9a-f]{32}$/.test(value) ? value : null +} + function normalizeProblem(error: unknown) { if (error instanceof HttpProblem) return error if (error instanceof z.ZodError) return badRequest(z.prettifyError(error)) diff --git a/src/core/adapter.ts b/src/core/adapter.ts index 8e19a11..ecf2aab 100644 --- a/src/core/adapter.ts +++ b/src/core/adapter.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono' export type RequestFailure = { type: string; error?: { name: string; message: string; stack?: string } } -export type AdapterVariables = { requestId: string; failure?: RequestFailure } +export type AdapterVariables = { requestId: string; correlationId: string; failure?: RequestFailure } export type AdapterEnv = { Variables: AdapterVariables } export interface AdapterModule { diff --git a/src/providers/github/client.ts b/src/providers/github/client.ts index bd785ee..7732bbf 100644 --- a/src/providers/github/client.ts +++ b/src/providers/github/client.ts @@ -34,6 +34,8 @@ type GitHubClientInput = { privateKey: string apiOrigin: string fetcher?: typeof fetch + cache?: Cache + waitUntil?: (promise: Promise) => void now?: () => number } @@ -81,8 +83,21 @@ export function createGitHubProvider(input: GitHubClientInput): GitHubProvider { } async function readAppPermissions() { + const cacheKey = new Request( + `https://cache.realmroot.invalid/github/apps/${encodeURIComponent(input.appId)}/permissions`, + ) + const cached = await input.cache?.match(cacheKey) + if (cached) return permissionsSchema.parse(await cached.json()) const response = await githubRequest(new Request(new URL('/app', input.apiOrigin)), await appJwt()) - return appSchema.parse(await response.json()).permissions + const permissions = appSchema.parse(await response.json()).permissions + if (input.cache) { + const write = input.cache.put( + cacheKey, + Response.json(permissions, { headers: { 'cache-control': 'public, max-age=10' } }), + ) + input.waitUntil ? input.waitUntil(write) : await write + } + return permissions } async function githubRequest(request: Request, token: string, requireSuccess = true, mode: 'api' | 'git' = 'api') { diff --git a/src/worker.ts b/src/worker.ts index ef58cda..97523cd 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,3 +1,4 @@ +import { tracing } from 'cloudflare:workers' import { createApp } from './app.js' import { loadConfig } from './config.js' import type { AdapterModule } from './core/adapter.js' @@ -29,139 +30,144 @@ import { D1RuntimeState } from './storage/d1-runtime-state.js' export default { async fetch(request, env, executionContext) { - const config = loadConfig(env, request.url) - const githubConfig = loadGitHubConfig(env, config) - const cloudflareConfig = loadCloudflareConfig(env, config) - const linearConfig = loadLinearConfig(env, config) - const state = new D1RuntimeState(env.DB) - const oauthStore = new D1ExternalOAuthStore(env.DB) - const signingPrivateJwk = config.oauthSigningPrivateJwk ? JSON.parse(config.oauthSigningPrivateJwk) : undefined - const adapters: AdapterModule[] = [] + return tracing.enterSpan('adapter.request.prepare', async (span) => { + span.setAttribute('url.path', new URL(request.url).pathname) + const config = loadConfig(env, request.url) + const githubConfig = loadGitHubConfig(env, config) + const cloudflareConfig = loadCloudflareConfig(env, config) + const linearConfig = loadLinearConfig(env, config) + const state = new D1RuntimeState(env.DB) + const oauthStore = new D1ExternalOAuthStore(env.DB) + const signingPrivateJwk = config.oauthSigningPrivateJwk ? JSON.parse(config.oauthSigningPrivateJwk) : undefined + const adapters: AdapterModule[] = [] - if ( - githubConfig.githubAppId && - githubConfig.githubPrivateKey && - githubConfig.githubClientId && - githubConfig.githubClientSecret && - signingPrivateJwk - ) { - const githubConnections = new D1GitHubConnections(env.DB, state) - const githubProvider = createGitHubProvider({ - appId: githubConfig.githubAppId, - privateKey: githubConfig.githubPrivateKey, - apiOrigin: githubConfig.githubApiOrigin, - }) - const githubExternal = createGitHubExternalAuthorization({ - origin: config.origin, - connection: createGitHubConnectionProvider({ + if ( + githubConfig.githubAppId && + githubConfig.githubPrivateKey && + githubConfig.githubClientId && + githubConfig.githubClientSecret && + signingPrivateJwk + ) { + const githubConnections = new D1GitHubConnections(env.DB, state) + const githubProvider = createGitHubProvider({ appId: githubConfig.githubAppId, privateKey: githubConfig.githubPrivateKey, - clientId: githubConfig.githubClientId, - clientSecret: githubConfig.githubClientSecret, - redirectUri: `${config.origin}/github/oauth/callback`, apiOrigin: githubConfig.githubApiOrigin, - }), - connections: githubConnections, - oauthStore, - scopes: permissionsToScopes(await githubProvider.appPermissions()), - }) - const githubAuthorization = await createExternalAuthorizationServer({ - origin: config.origin, - provider: githubExternal.authorization, - providerCallbackPath: '/github/oauth/callback', - store: oauthStore, - signingPrivateJwk, - replayStore: state, - }) - adapters.push( - githubAuthorization, - githubExternal.installationCallback, - createGitHubAdapter(githubConfig, { - authenticator: githubAuthorization.authenticator, - provider: githubProvider, - audit: (record) => state.recordAudit(record), + cache: caches.default, + waitUntil: (promise) => executionContext.waitUntil(promise), + }) + const githubExternal = createGitHubExternalAuthorization({ + origin: config.origin, + connection: createGitHubConnectionProvider({ + appId: githubConfig.githubAppId, + privateKey: githubConfig.githubPrivateKey, + clientId: githubConfig.githubClientId, + clientSecret: githubConfig.githubClientSecret, + redirectUri: `${config.origin}/github/oauth/callback`, + apiOrigin: githubConfig.githubApiOrigin, + }), connections: githubConnections, - }), - ) - } + oauthStore, + scopes: permissionsToScopes(await githubProvider.appPermissions()), + }) + const githubAuthorization = await createExternalAuthorizationServer({ + origin: config.origin, + provider: githubExternal.authorization, + providerCallbackPath: '/github/oauth/callback', + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + githubAuthorization, + githubExternal.installationCallback, + createGitHubAdapter(githubConfig, { + authenticator: githubAuthorization.authenticator, + provider: githubProvider, + audit: (record) => state.recordAudit(record), + connections: githubConnections, + }), + ) + } - if ( - linearConfig.linearClientId && - linearConfig.linearClientSecret && - linearConfig.linearCredentialEncryptionKey && - signingPrivateJwk - ) { - const linearConnections = new D1LinearConnections( - env.DB, - createLinearCredentialCipher(linearConfig.linearCredentialEncryptionKey), - state, - ) - const linearProvider = createLinearProvider({ - clientId: linearConfig.linearClientId, - clientSecret: linearConfig.linearClientSecret, - redirectUri: `${config.origin}/linear/oauth/callback`, - apiOrigin: linearConfig.linearApiOrigin, - authorizationOrigin: linearConfig.linearAuthorizationOrigin, - }) - const linearAuthorization = await createExternalAuthorizationServer({ - origin: config.origin, - provider: createLinearExternalAuthorization({ + if ( + linearConfig.linearClientId && + linearConfig.linearClientSecret && + linearConfig.linearCredentialEncryptionKey && + signingPrivateJwk + ) { + const linearConnections = new D1LinearConnections( + env.DB, + createLinearCredentialCipher(linearConfig.linearCredentialEncryptionKey), + state, + ) + const linearProvider = createLinearProvider({ + clientId: linearConfig.linearClientId, + clientSecret: linearConfig.linearClientSecret, + redirectUri: `${config.origin}/linear/oauth/callback`, + apiOrigin: linearConfig.linearApiOrigin, + authorizationOrigin: linearConfig.linearAuthorizationOrigin, + }) + const linearAuthorization = await createExternalAuthorizationServer({ origin: config.origin, - provider: linearProvider, - connections: linearConnections, - scopes: linearScopes, - }), - providerCallbackPath: '/linear/oauth/callback', - store: oauthStore, - signingPrivateJwk, - replayStore: state, - }) - adapters.push( - linearAuthorization, - createLinearAdapter(linearConfig, { - authenticator: linearAuthorization.authenticator, - provider: linearProvider, - connections: linearConnections, - audit: (record) => state.recordAudit(record), - }), - ) - } - if (cloudflareConfig) { - if (!signingPrivateJwk) throw new Error('Cloudflare external authorization is not configured.') - const cloudflareCredentials = new D1CloudflareCredentials( - env.DB, - createCredentialCipher(cloudflareConfig.credentialEncryptionKey), - ) - const cloudflareProvider = createCloudflareOAuthProvider({ - clientId: cloudflareConfig.clientId, - clientSecret: cloudflareConfig.clientSecret, - redirectUri: `${config.origin}/oauth/cloudflare/provider/callback`, - authorizationOrigin: cloudflareConfig.authorizationOrigin, - }) - const cloudflareAuthorization = await createExternalAuthorizationServer({ - origin: config.origin, - provider: createCloudflareExternalAuthorization({ + provider: createLinearExternalAuthorization({ + origin: config.origin, + provider: linearProvider, + connections: linearConnections, + scopes: linearScopes, + }), + providerCallbackPath: '/linear/oauth/callback', + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + linearAuthorization, + createLinearAdapter(linearConfig, { + authenticator: linearAuthorization.authenticator, + provider: linearProvider, + connections: linearConnections, + audit: (record) => state.recordAudit(record), + }), + ) + } + if (cloudflareConfig) { + if (!signingPrivateJwk) throw new Error('Cloudflare external authorization is not configured.') + const cloudflareCredentials = new D1CloudflareCredentials( + env.DB, + createCredentialCipher(cloudflareConfig.credentialEncryptionKey), + ) + const cloudflareProvider = createCloudflareOAuthProvider({ + clientId: cloudflareConfig.clientId, + clientSecret: cloudflareConfig.clientSecret, + redirectUri: `${config.origin}/oauth/cloudflare/provider/callback`, + authorizationOrigin: cloudflareConfig.authorizationOrigin, + }) + const cloudflareAuthorization = await createExternalAuthorizationServer({ origin: config.origin, - provider: cloudflareProvider, - credentials: cloudflareCredentials, - scopes: Object.keys(cloudflareManifest.scopes), - }), - store: oauthStore, - signingPrivateJwk, - replayStore: state, - }) - adapters.push( - cloudflareAuthorization, - createCloudflareAdapter(cloudflareConfig, { - authenticator: cloudflareAuthorization.authenticator, - provider: cloudflareProvider, - credentials: cloudflareCredentials, - audit: (record) => state.recordAudit(record), - fetch, - }), - ) - } - const app = createApp(adapters) - return app.fetch(request, env, executionContext) + provider: createCloudflareExternalAuthorization({ + origin: config.origin, + provider: cloudflareProvider, + credentials: cloudflareCredentials, + scopes: Object.keys(cloudflareManifest.scopes), + }), + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + cloudflareAuthorization, + createCloudflareAdapter(cloudflareConfig, { + authenticator: cloudflareAuthorization.authenticator, + provider: cloudflareProvider, + credentials: cloudflareCredentials, + audit: (record) => state.recordAudit(record), + fetch, + }), + ) + } + const app = createApp(adapters) + return tracing.enterSpan('adapter.router.dispatch', () => app.fetch(request, env, executionContext)) + }) }, } satisfies ExportedHandler diff --git a/test/observability.test.ts b/test/observability.test.ts new file mode 100644 index 0000000..22ed1b8 --- /dev/null +++ b/test/observability.test.ts @@ -0,0 +1,23 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createApp } from '../src/app.js' + +afterEach(() => vi.restoreAllMocks()) + +it('emits one correlated request completion event without trusting caller request identity', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + const response = await createApp([]).request('/health', { + headers: { 'x-correlation-id': '0123456789abcdef0123456789abcdef' }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('request-id')).toBeTruthy() + expect(info).toHaveBeenCalledOnce() + const [entry] = info.mock.calls + expect(JSON.parse(entry?.[0] ?? '{}')).toMatchObject({ + event: 'request.complete', + correlationId: '0123456789abcdef0123456789abcdef', + method: 'GET', + path: '/health', + status: 200, + }) +}) diff --git a/test/providers/github-client.test.ts b/test/providers/github-client.test.ts index e0ef60c..29d65c4 100644 --- a/test/providers/github-client.test.ts +++ b/test/providers/github-client.test.ts @@ -73,6 +73,28 @@ describe('GitHub provider HTTP boundary', () => { }) await expect(provider.appPermissions()).resolves.toEqual({ metadata: 'read', issues: 'write' }) }) + + it('reuses Cloudflare Cache API App permissions across provider instances', async () => { + const responses = new Map() + const cache = { + match: async (request: RequestInfo | URL) => responses.get(String(request))?.clone(), + put: async (request: RequestInfo | URL, response: Response) => { + responses.set(String(request), response.clone()) + }, + } as Cache + const input = { + appId: '123', + privateKey: privateKey('pkcs8'), + apiOrigin, + cache, + now: () => 1_800_000_000_000, + } + + await createGitHubProvider(input).appPermissions() + await createGitHubProvider(input).appPermissions() + + expect(seen.filter((request) => request.url === '/app')).toHaveLength(1) + }) }) describe('GitHub account connection OAuth boundary', () => { diff --git a/wrangler.jsonc b/wrangler.jsonc index 2cf5def..ee6c71e 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -46,7 +46,7 @@ ], "observability": { "enabled": true, - "logs": { "head_sampling_rate": 1 }, - "traces": { "enabled": true, "head_sampling_rate": 0.1 } + "logs": { "head_sampling_rate": 1, "invocation_logs": false }, + "traces": { "enabled": true, "head_sampling_rate": 1 } } }