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
10 changes: 9 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion src/core/adapter.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion src/providers/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type GitHubClientInput = {
privateKey: string
apiOrigin: string
fetcher?: typeof fetch
cache?: Cache
waitUntil?: (promise: Promise<unknown>) => void
now?: () => number
}

Expand Down Expand Up @@ -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') {
Expand Down
258 changes: 132 additions & 126 deletions src/worker.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<Env>
23 changes: 23 additions & 0 deletions test/observability.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
Loading
Loading